1️⃣ 什么是多态?

众所周知,多态 是面向对象三大特性的核心。很多朋友停留在 “加个 virtual 关键字就能实现多态” 的表层认知。

多态的核心在于:同一个接口,不同的实现。举个简单的例子:

假设有一台通用机器人(基类),它能干很多事,比如打扫、修理等。咱们根据实际需要,造出了清洁机器人(派生类)和修理机器人(另一个派生类),它们具体干的活就不一样了。

那么,在 C++ 实现多态的底层原理是什么呢?这一切的答案就在于 虚函数表。接下来,将会从以下几个方面进行讲解:

  • C++ 的绑定方式
  • 虚函数表 vtable 的结构
  • 虚指针 vptrvtable 的关系以及内存布局
  • 虚函数的实现流程
  • 面试常问问题

2️⃣ C++ 的绑定方式

C++ 中,“绑定” 通常是将 “函数调用” 和 具体的 “函数实现” 关联起来。这个过程可能发生在 编译期,也可能发生在 运行期,因此,C++ 中的绑定方式分为 静态绑定动态绑定

静态绑定

静态绑定 又称 早绑定,是 C++ 中默认的绑定方式。

  • 发生时机:编译阶段 由编译器完成。
  • 核心逻辑: 编译器根据调用的对象类型、函数名及参数列表,直接确定要执行的代码地址。
  • 代码案例:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    #include<iostream>
    using namespace std;

    // 函数重载示例
    void display(int i) {
    cout << "Here is int: " << i << endl;
    }

    void display(double d) {
    cout << "Here is double: " << d << endl;
    }

    int main() {
    display(5); // 调用 int 版本的 display
    display(5.5); // 调用 double 版本的 display
    return 0;
    }

动态绑定

动态绑定 又称 晚绑定,主要用来实现 多态

  • 发生时机:运行阶段 由对象的实际类型来确定。
  • 核心逻辑: 通过 虚函数表虚函数指针 来实现。程序在 运行时 查看对象的实际类型,从而调用对应派生类或基类中的函数。
  • 代码案例:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    class Base {
    public:
    virtual void show() {
    cout << "Base class" << endl;
    }
    };

    class Derived : public Base {
    public:
    void show() override {
    cout << "Derived class" << endl;
    }
    };

    int main() {
    Base* basePtr = new Derived();
    basePtr->show(); // Outputs: Derived class
    delete basePtr;
    return 0;
    }

3️⃣ 虚函数表 vtable

定义:类级别的全局只读函数指针数组

  • 存储位置: vtable 存于程序的 .rodata 段(只读数据区)。与全局变量、字符串常量同级,生命周期与程序一致(程序退出时释放);

  • 核心作用: 作为 “地址映射表”,关联类与虚函数的实现。

    • Base 类的 vtableBase::foo() 的地址
    • Derived 类的 vtableDerived::foo() 的地址。
  • 代码案例:类 A 有 3 个虚函数foo()bar()baz(),其 vtable 的结构如下:

    索引 函数地址 对应函数
    0 &A::foo foo()
    1 &A::bar bar()
    2 &A::baz baz()
    3 &type_info for A RTTI 信息(可选)

4️⃣ 虚指针 vptr 及其内存布局

定义:虚指针 vptr 是对象实例中的一个隐藏成员,指向当前对象所属类的虚函数表。

  • 大小: 4/8 字节,由平台决定。
  • 位置: 有编译器决定,通常位于 对象内存头部

vtablevptr 的关系

  • 一个类只有一个 vtable
  • 通过类生成的对象都有一个 vptr,指向类的 vtable
  • 多对象共享 vtable

内存布局

不含虚函数

  • 空类
    • 大小: 1 B
    • 原因: 编译器的优化,为了让 “两个空类对象的地址不同”

包含虚函数

  • 空类
    • 大小: 4/8 B
    • 原因: 类中的成员隐含了虚指针 vptr

5️⃣ 虚函数机制实现流程

接下来,通过一个案例来讲解 动态绑定 的实现

1
2
Robot* robot = new CleaningRobot();
robot->performTask();
  • 编译期
    • 编译器为类 RobotCleaningRobot 生成各自的虚函数表 vtable
    • 虚函数表 vtable 存储该类的所有虚函数地址。
    • 对于派生类 CleaningRobot,如果重写了基类 Robot 的某个虚函数(例如 performTask),编译器会将派生类的函数地址覆盖基类虚函数表中对应的位置。这一覆盖过程是编译器在生成 CleaningRobot 的虚函数表时完成的。
    • 与此同时,编译器会为支持虚函数的类(如 RobotCleaningRobot)的对象添加一个隐式指针 vptr
    • 编译器还会在构造函数中插入代码,用于初始化对象的 vptr
  • 基类指针指向派生类对象:
    • Robot 是一个基类类型的指针,但它实际指向的是 CleaningRobot 类型的对象。
    • 对象构造过程中,vptr 被依次初始化。
      • 首先调用基类的构造函数,将 vptr 指向 Robot 的虚函数表
      • 接着调用派生类的构造函数,更新 vptr,最终指向 CleaningRobot 的虚函数表。
  • 运行期
    • 当调用 robot->performTask() 时,程序通过 robot 指针访问到 CleaningRobot 对象的 vptr
    • vptr 指向 CleaningRobot 的虚函数表,程序在虚函数表中根据函数的索引找到 CleaningRobot::performTask 的地址。
  • 跳转到派生类函数执行:
    • 最终,程序根据虚函数表中记录的地址跳转到 CleaningRobot::performTask 并执行该函数。

示意图

6️⃣ 多重继承

多重继承(派生类有多个直接基类)是复杂场景的核心

  • 派生类的 vptr 数量与直接基类数量一致,
  • 每个基类对应一个 vtable,派生类新增的虚函数追加到 “第一个基类的 vtable” 中。

案例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Base1 {
public:
virtual void foo1() { cout << "Base1::foo1" << endl; }
int x;
};

class Base2 {
public:
virtual void foo2() { cout << "Base2::foo2" << endl; }
int y;
};

class Derived : public Base1, public Base2 {
public:
virtual void baz() { cout << "Derived::baz" << endl; } // 新增
};
  • Derived 有 2 个直接基类 Base1Base2,因此包含 2 个 vptr
    • vptr1:指向 Base1vtable

      包含 Base1 的虚函数 以及 Derived 新增的 baz()

    • vptr2:指向 Base2vtable

      仅包含 Base2vtable

  • 指针偏移
    • 不同基类指针指向同一派生类对象时,地址可能不同!!!
      1
      2
      3
      Derived* d = new Derived();
      Base1* b1 = d; // b1地址 == d地址(指向vptr1)
      Base2* b2 = d; // b2地址 != d地址(指向vptr2,需要偏移)
    • 解析:
      • 编译器需要让 b2vptr 指向 Base2vtable,因此会自动计算偏移量。

        比如 d 地址 + 8 字节,跳过 vptr1x)。

      • 如果手动强制转换,如 (Derived*) b2 ,会导致地址计算错误,访问到错误的 vptr,进而调用错误的虚函数(甚至崩溃)。

菱形继承

菱形继承 指的是:一个派生类 Derived 通过两个间接基类 Base1Base2 来继承同一个基类 Base。此时,会导致基类 Base 的成员二义性问题。

1
2
3
4
5
6
7
8
9
class Base { 
public:
virtual void foo() {};
int x;
};

class Base1 : public Base {}; // 非虚继承
class Base2 : public Base {}; // 非虚继承
class Derived : public Base1, public Base2 {};

此时 Derived 会有 2 个 Base 的副本(x1x2)和 2 个 Basevtable

调用 Derived d; d.x 会报二义性错误,vtable 也存在冗余。

底层优化:Offset Table

方法:虚继承通过 “共享基类 + Offset Table(偏移量表)” 解决菱形继承问题。

1
2
class Base1 : virtual public Base {}; // 虚继承
class Base2 : virtual public Base {}; // 虚继承
  • Derived 的底层结构会新增一个 Offset Table(偏移量表),它存储 “派生类对象 Derived共享基类 Base 的偏移量”,确保所有基类指针都能找到同一个 Base 副本。
  • vtable 结构发生变化,Base1Base2vtable 会包含 “指向 Offset Table 的指针”,而非直接包含 Basevtable—— 这样 Basevtable 被共享,避免冗余。

7️⃣ 纯虚函数

纯虚函数 是抽象类的核心,表现形式常如下:

1
2
3
class Base {
virtual void foo() = 0;
};

其常呈现以下两个特点:

  • vtable 中的地址通常是 NULL
    • 纯虚函数在 vtable 中会占据一个条目,但地址通常是NULL 或特殊标记。
  • 不可被实例化
    • 因为抽象类的 vtable 中存在 “未实现的纯虚函数条目”(如 NULL)。如果允许实例化,调用纯虚函数会导致 “空指针调用” 崩溃 —— 因此编译器会禁止抽象类实例化

8️⃣ RTTI

RTTIRun-Time Type Information 的简称,常被称为: C++ 运行时类型识别

其核心依赖虚函数表 —— typeiddynamic_cast 都通过 vtable 获取类型信息。

接下来,会讲解具体的工作原理:

1️⃣ type_info 结构体

  • 每个含 虚函数 的类,其 vtable 中会存储一个type_info 结构体的指针。
  • type_info 包含类名、类型 ID 等元数据,是 RTTI 的核心数据结构。

2️⃣ typeid 的工作流程

当调用 typeid(ptr)ptrBase*)时,流程是:

  • 通过 ptr 指向的对象,获取其 vptr
  • 通过 vptr 找到 vtable,获取 type_info 指针;
  • 返回 type_info 对象,用于比较类型。

3️⃣ dynamic_cast 的底层逻辑

dynamic_cast<Derived*>(base_ptr) 用于 “基类指针向派生类指针的安全转换”,底层依赖 vtable

  • 通过 base_ptrvptr 找到 vtable,获取type_info
  • 检查目标类型(Derived)是否与 type_info 匹配,或是否是派生关系;
  • 若匹配,返回调整后的派生类指针;若不匹配,返回 NULL(指针)或抛出异常(引用)。

9️⃣ 面试常问问题

虚表会不会拖慢程序?

不会太慢。

  • 虚函数表查找的时间复杂度是 O(1),可以理解为一次 “定向跳转”。
  • 虽然比普通函数调用稍慢一点,但性能差距微乎其微,几乎可以忽略不计。

虚函数表是在编译期间还是运行期间生成的?

  • vtable 是在 编译阶段 生成的,每个含有虚函数 都会有一张自己的 虚函数表
  • 在运行时,程序会通过对象的 vptr 指向对应的虚函数表。

如果一个类没有虚函数,还会有虚函数表吗?

不会。

  • 如果一个 没有 虚函数,就不会生成 vtable 虚函数表,也没有 vptr(虚指针)。
  • 这种情况下,函数调用 就是普通的 “静态绑定”,编译器在 编译时 就决定了调用哪个函数。

虚函数表是对象级别的吗?

不是!虚函数表是类级别的,每个类只会生成一张虚函数表。

  • 所有属于这个类的对象共享同一张虚函数表,每个对象只需要一个虚指针(vptr)指向这张表。

  • 如果每个对象都存储一整张虚函数表,会浪费大量内存,因此虚函数表设计为类级别的,共享使用。

为什么析构函数要设为虚函数?

  • 如果 析构函数 不是 虚函数基类指针 指向 派生类对象 时,delete ptr 会触发静态绑定;
  • 此时只调用 基类析构函数,导致派生类资源泄漏
  • 设为虚函数后,delete ptr 会通过 vtable 动态绑定到派生类析构函数,确保基类和派生类都被正确析构。

构造函数和析构函数中调用虚函数,会发生什么?为什么?

会执行当前构造 / 析构类的虚函数版本,而非派生类版本。

  • 构造/析构时,对象的类型是 “当前类”,vptr 指向当前类的 vtable
  • 基类构造时,vptr 指向基类 vtable,调用虚函数会执行 基类版本,避免访问未初始化的派生类成员。

多继承时,派生类有几个 vptr?

派生类的 vptr 数量与 “直接基类的数量” 一致。

  • 派生类 新增的虚函数,会追加到 第一个基类vtable 中。

什么是对象切片?如何避免?

对象切片 是指用 派生类对象基类对象 进行 赋值 ,派生类的部分(包括 vptr)被 “切掉”,仅保留基类部分,导致 vptr 指向基类 vtable,虚函数调用失效。

避免方法:用指针或引用传递多态对象,而非值传递。

C++RTTI 依赖什么实现?dynamic_cast 和typeid 的底层逻辑是什么?

RTTI 依赖 虚函数表 实现,vtable 中存储了type_info 结构体的指针。

  • typeid(ptr):通过 ptrvptr 找到 vtable,获取 type_info 指针,返回 type_info 对象;
  • dynamic_cast<Derived*>(base_ptr):通过 base_ptrvptr 获取 type_info,检查是否与 Derived 匹配,若匹配则返回调整后的指针,否则返回 NULL。

纯虚函数和抽象类的关系是什么?抽象类为什么不能实例化?

含纯虚函数的类是抽象类。 抽象类不能实例化的原因是:

  • 纯虚函数vtable 中的条目是 NULL(或特殊标记)
  • 如果允许实例化,调用纯虚函数会导致空指针调用崩溃,因此编译器禁止抽象类实例化。

参考资料