基于本文回答

播面 播面

文图音视,全方位拆解八股文
0
评论

什么是虚函数表(vtable)和虚表指针(vptr)?它们是如何工作的?

知识点图片

在 C++ 中,虚函数表(Virtual Table,简称 vtable)虚表指针(Virtual Pointer,简称 vptr) 是编译器用来实现动态多态(Runtime Polymorphism)动态绑定(Late Binding)的底层机制。

简单来说,当你使用基类指针或引用去调用派生类中重写的虚函数时,C++ 就是通过 vtable 和 vptr 来准确找到究竟该执行哪个版本(基类还是派生类)的函数的。

下面详细拆解它们是什么以及是如何工作的:


1. 核心概念

A. 虚函数表 (vtable)

  • 本质: 它是一个函数指针数组
  • 归属: 它属于类(Class)级别。也就是说,同一个类的所有对象共享同一张虚函数表。
  • 生成时机:编译期由编译器自动生成。
  • 内容: 表中记录了该类所有虚函数的实际内存地址。如果派生类重写(override)了基类的虚函数,vtable 中对应的函数指针就会被替换为派生类函数的地址;如果没有重写,则保留基类函数的地址。

B. 虚表指针 (vptr)

  • 本质: 它是一个隐藏的指针变量
  • 归属: 它属于对象(Object / Instance)级别。只要一个类包含虚函数,编译器就会在实例化该类对象时,暗中为该对象增加一个指针。
  • 初始化时机:运行期,当对象被创建(执行构造函数)时,vptr 被初始化,指向该对象所属类的 vtable。

2. 它们是如何工作的?(工作流程)

我们通过一段代码和内存模型来还原它的工作过程。

代码示例

cpp
#include <iostream>

class Animal {
public:
    virtual void speak() { std::cout << "Animal speaks" << std::endl; }
    virtual void walk()  { std::cout << "Animal walks" << std::endl; }
};

class Dog : public Animal {
public:
    // 重写了 speak,但没有重写 walk
    void speak() override { std::cout << "Dog barks" << std::endl; }
};

int main() {
    Animal* myPet = new Dog();
    myPet->speak(); // 动态绑定:输出 "Dog barks"
    myPet->walk();  // 动态绑定:输出 "Animal walks"
    
    delete myPet;
    return 0;
}

编译期:构建 vtable

编译器在编译这段代码时,发现 AnimalDog 都有虚函数,于是为它们分别生成虚函数表:

  • Animal 的 vtable:

    • Slot 0: 指向 Animal::speak() 的地址
    • Slot 1: 指向 Animal::walk() 的地址
  • Dog 的 vtable:

    • Slot 0: 指向 Dog::speak() 的地址 (因为重写了,所以替换掉 Animal 的版本
    • Slot 1: 指向 Animal::walk() 的地址 (因为没重写,所以继承基类的版本

运行期:初始化 vptr 与 函数调用

  1. 创建对象: new Dog() 被执行,内存中分配了一块空间给 Dog 对象。在调用 Dog 的构造函数时,编译器悄悄插入了代码,将这个 Dog 对象的 vptr 指向了 Dog类的 vtable
  2. 指针赋值: Animal* myPet = new Dog();。此时,基类指针 myPet 指向了这个派生类对象。
  3. 调用函数: 当执行 myPet->speak(); 时:
    • 编译器发现 speak() 是个虚函数。
    • 不再直接生成跳转到 Animal::speak() 的机器码
    • 而是生成类似这样的寻址代码:
      1. 找到 myPet 指向的对象。
      2. 提取对象内部的 vptr
      3. 顺着 vptr 找到对应的虚函数表(即 Dog 的 vtable)。
      4. 在 vtable 中查找 speak() 对应的偏移量(Slot 0)。
      5. 取出 Slot 0 中的函数地址,并执行跳转(Jump)。

因为 Dog 的 vtable 中 Slot 0 存的是 Dog::speak(),所以最终执行了狗的叫声。


3. 内存布局图解

为了更直观,我们可以用下图表示上述代码在运行时的内存布局:

plaintext
[ 指针 myPet ] -----------------> [ Dog 对象实例 (堆内存) ]
                                  |-------------------|
                                  | vptr  ----------| |
                                  |-----------------|-|
                                  | Dog 的其他成员  | |
                                  |-------------------|
                                                    |
                                                    V
                                    [ Dog 的 vtable (只读数据段) ]
                                    |-----------------------|
                      Slot 0 -----> | &Dog::speak()         |
                      Slot 1 -----> | &Animal::walk()       |
                                    |-----------------------|

如果你创建了一个 Animal 的实例 (new Animal()),它的布局是这样的:

plaintext
                                  [ Animal 对象实例 ]
                                  |-------------------|
                                  | vptr  ----------| |
                                  |-----------------|-|
                                  | Animal 其它成员 | |
                                  |-------------------|
                                                    |
                                                    V
                                    [ Animal 的 vtable ]
                                    |-----------------------|
                      Slot 0 -----> | &Animal::speak()      |
                      Slot 1 -----> | &Animal::walk()       |
                                    |-----------------------|

4. 重要的推论与代价(面试常考点)

引入虚函数表机制并不是免费的,它带来了空间和时间上的开销:

  1. 空间开销(Space Overhead):
    • 对象体积增大: 每个包含虚函数的对象,都会多出一个指针的体积(在 32 位系统上是 4 字节,在 64 位系统上是 8 字节)。
    • 内存占用增加: 每个带有虚函数的类,都需要在可执行文件中保存一张虚函数表。
  2. 时间开销(Time Overhead):
    • 调用变慢: 虚函数调用需要经过“对象 -> vptr -> vtable -> 取出函数地址 -> 调用”这样一个间接寻址的过程,比普通的函数直接调用(静态绑定)要慢一点。
    • 无法内联(Inline): 因为具体调用哪个函数要在运行时才能确定,所以编译器通常无法对虚函数进行内联优化(除非在编译期能推断出确切的类型)。
  3. 构造函数不能是虚函数:
    • 在构造函数执行期间,对象的 vptr 是逐步被初始化的(先初始化基类部分的 vptr,再初始化派生类部分的 vptr)。如果在构造函数中调用虚函数,它只会调用当前正在构造的类层级的版本,不会发生多态。
  4. 基类的析构函数必须是虚函数:
    • 如果基类析构函数不是虚函数,当 delete myPet;myPet 是基类指针)时,编译器只会静态绑定到基类的析构函数,导致派生类的析构函数不被执行,引发内存泄漏。如果加了 virtual,就会通过 vtable 正确查找到派生类的析构函数并执行。
00:00
00:00