C++ 多态的本质与虚函数表深度解析

本文从底层机制出发,深入剖析多态的本质、虚函数表(vtable)的内存布局与调用流程,涵盖单继承、多继承、虚继承下的虚表结构,以及反汇编层面的动态绑定原理。


第一部分:多态的本质

1.1 什么是多态——从两个维度理解

多态(Polymorphism)的字面意思是"多种形态"。在 C++ 中,同一个接口可以根据对象的具体类型表现出不同的行为。

维度 静态多态 (编译时) 动态多态 (运行时)
实现机制 函数重载 / 模板 虚函数 + 继承 + 父类指针或引用
绑定时机 编译阶段确定调用地址 运行时通过虚表查找调用地址
关键字 无特殊关键字 virtual
性能 零开销(直接 call) 间接寻址开销(call [reg+offset])
灵活性 低,类型在编译时已固定 高,可运行时切换行为
典型场景 max(int,int) / max(double,double) Animal* p = new Dog; p->speak();

核心结论:静态多态的本质是"编译时决议",动态多态的本质是"运行时查表"。

1.2 动态多态的两个核心条件

条件一:必须是父类的指针或引用去调用虚函数。

条件二:子类必须重写(override)父类的虚函数(三同:返回值、函数名、参数列表)。

1.3 为什么必须是父类的指针或引用?

这是理解多态本质的第一个关键点。分三种情况说明。

情况一:值传递——多态失效

Animal a = Dog();   // 切片!Dog 部分被切掉,只剩 Animal 部分
a.speak();           // 调用 Animal::speak,不是 Dog::speak

值传递发生切片(slicing),对象被截断为父类类型,虚表指针也被覆盖为父类的虚表。

情况二:指针传递——多态生效

Animal* p = new Dog();  // p 指向完整的 Dog 对象
p->speak();              // 通过虚表找到 Dog::speak

指针只存地址,不改变对象本身。p 指向的内存空间中,虚表指针仍然是 Dog 的虚表。

情况三:引用传递——多态生效

Dog d;
Animal& r = d;       // r 引用 d,不创建新对象
r.speak();           // 通过虚表找到 Dog::speak

引用本质是指针的语法糖,同样不改变对象的内存布局。

核心结论:多态的前提是"不破坏对象的原始虚表指针"。值传递会导致切片并覆盖虚表指针,因此多态失效;指针和引用保留了原始内存中的虚表指针,因此多态成立。

1.4 重写的本质——“复制粘贴”

子类重写父类虚函数时,编译器做了以下事情:

将父类虚函数的 virtual 标记、返回值类型、函数名、参数列表类型名、缺省值,原封不动复制到子类的同名函数中。子类只替换了函数体,其余一切来自父类。因此子类加了 virtual 是"多余的"但仍建议加(可读性),因为 virtual 已经被父类传下来了。

class A {
public:
    virtual void func(int val = 1) { cout << "A:" << val; }  // 父类
};

class B : public A {
public:
    void func(int val = 0) { cout << "B:" << val; }  // 子类
    // 编译器实际处理后的等价形式:
    // virtual void func(int val = 1) { cout << "B:" << val; }
    //                         ^ 缺省值被替换为父类的1,不是子类的0
};

注意:缺省值(默认参数)属于"声明"的一部分,在重写时从父类复制。因此多态调用时使用的缺省值永远是父类的。这是著名的"默认参数陷阱"。

1.5 多态调用中 this 指针的真相

一个常被忽略但至关重要的事实:

子类继承父类时,父类部分的 this 指针类型仍然是父类类型,不是子类类型。多态的发生点在于:父类成员函数内部通过 this 调用另一个虚函数时,this 虽然类型是父类,但它指向的内存空间包含子类的虚表指针。编译器顺着虚表指针找到子类的虚表,从而调用子类的虚函数。

class A {
public:
    virtual void func() { cout << "A::func" << endl; }
    void callFunc() { this->func(); }  // this 类型为 A*,但可能指向 B 对象
};

class B : public A {
public:
    virtual void func() { cout << "B::func" << endl; }
};

int main() {
    B b;
    b.callFunc();  // 输出 B::func
    // this 是 A* 类型,但它指向的内存中虚表是 B 的虚表
}

第二部分:虚函数表深度解析

2.1 虚函数表是什么

每个包含虚函数的类(或继承自含虚函数的类),编译器都会为其生成一张虚函数表(vtable),同时在该类的每个对象中安插一个指向这张表的指针(_vfptr)。

内存布局模型:

对象内存布局:[ _vfptr (8字节/4字节) | 成员变量… ]

_vfptr 指向:虚函数表(函数指针数组)

虚表内容:[ &A::func1, &A::func2, …, 0x00000000 (结束标记) ]

虚表存储在:常量区(只读数据段 .rdata)

虚函数本身存储在:代码段(.text)

class Base {
public:
    virtual void func1() { cout << "func1"; }
    virtual void func2() { cout << "func2"; }
    int _a = 10;
};

int main() {
    Base b;
    // b 的内存布局(64位):
    // +0: _vfptr   -> &Base::vtable  (8字节)
    // +8: _a = 10                    (4字节)
    // +12: padding                   (4字节, 对齐到16)
    cout << sizeof(b);  // 16
}

2.2 编译器在背后做了什么

当编译器看到 virtual 关键字时,做了以下工作:

步骤一:为该类生成一张虚函数表,表中按声明顺序存放所有虚函数的地址。

步骤二:在类对象的开头安插一个隐藏的虚表指针 _vfptr。

步骤三:在构造函数中插入代码,将 _vfptr 赋值为本类的虚表地址。

步骤四:将所有通过指针/引用对虚函数的调用,转换为"查表 + 间接调用"。

2.3 虚函数调用的完整流程(核心)

以下详细追踪一个多态调用的完整过程。

class Animal {
public:
    virtual void speak() { cout << "Animal"; }
};

class Dog : public Animal {
public:
    virtual void speak() { cout << "Woof!"; }
};

int main() {
    Animal* p = new Dog();
    p->speak();   // 这一行到底发生了什么?
}
步骤 动作 汇编等价 说明
1 取对象地址 mov rax, [p] p 中存的是 Dog 对象的首地址
2 取虚表指针 mov rbx, [rax] 对象首 8 字节 = _vfptr = 虚表地址
3 查虚表取函数地址 mov rcx, [rbx] 虚表首项 = speak() 的地址
4 间接调用 call rcx 通过寄存器间接调用,而非直接 call 固定地址

核心结论:虚函数调用 = 两次间接寻址:对象 -> _vfptr -> 虚表 -> 函数地址。普通函数调用 = 一次直接寻址:call 固定地址。

2.4 单继承下的虚表结构

这是最常见的情况。子类只继承一个父类时,虚表的构造过程分三步:

拷贝:子类先将父类的虚函数表完整拷贝一份。

覆盖:子类重写了哪些虚函数,就用新地址覆盖虚表中对应的旧地址。

追加:子类自己新增的虚函数,按声明顺序追加到虚表末尾。

下面用一个完整例子来演示:

class Base {
public:
    virtual void f1() { cout << "Base::f1"; }
    virtual void f2() { cout << "Base::f2"; }
    virtual void f3() { cout << "Base::f3"; }
};

class Derive : public Base {
public:
    virtual void f1() { cout << "Derive::f1"; }  // 重写 f1
    virtual void f4() { cout << "Derive::f4"; }  // 新增 f4
    virtual void f5() { cout << "Derive::f5"; }  // 新增 f5
};
虚表槽位 Base 虚表内容 Derive 虚表内容 说明
[0] &Base::f1 &Derive::f1 被覆盖!Derive 重写了 f1
[1] &Base::f2 &Base::f2 未重写,保持原样
[2] &Base::f3 &Base::f3 未重写,保持原样
[3] - &Derive::f4 新增虚函数
[4] - &Derive::f5 新增虚函数
[5] 0x00000000 0x00000000 虚表结束标记

2.5 多继承下的虚表结构

当一个子类继承多个父类时,情况变得复杂。子类会拥有多张虚表,每张虚表对应一个父类。

class Base1 {
public:
    virtual void f1() { cout << "Base1::f1"; }
    virtual void f2() { cout << "Base1::f2"; }
};

class Base2 {
public:
    virtual void g1() { cout << "Base2::g1"; }
    virtual void g2() { cout << "Base2::g2"; }
};

class Derive : public Base1, public Base2 {
public:
    virtual void f1() { cout << "Derive::f1"; }   // 重写 Base1::f1
    virtual void g1() { cout << "Derive::g1"; }   // 重写 Base2::g1
    virtual void h1() { cout << "Derive::h1"; }   // 新增
};

Derive 对象内存布局(64位):

┌─────────────────────────┐  ← Derive* / Base1* 指向这里
│ _vfptr_Base1            │  → 虚表1 (Base1 部分)
├─────────────────────────┤
│ Base1 的成员变量        │
├─────────────────────────┤  ← Base2* 指向这里(地址偏移了!)
│ _vfptr_Base2            │  → 虚表2 (Base2 部分)
├─────────────────────────┤
│ Base2 的成员变量        │
├─────────────────────────┤
│ Derive 自己的成员变量   │
└─────────────────────────┘

多继承关键点:

Derive 对象中有两张虚表指针:_vfptr_Base1 和 _vfptr_Base2。Base1* 指向对象首地址,Base2* 指向对象内 Base2 子对象的起始地址(地址偏移了)。Derive 自己新增的虚函数被追加到第一张虚表(Base1 虚表)的末尾。当通过 Base2* 调用虚函数时,需要通过 thunk/adjustor 修正 this 指针,使其指向 Derive 对象的首地址。

2.6 this 指针调整(thunk 技术)

这是多继承中最微妙的部分。当通过 Base2* 调用重写的虚函数 g1 时:

// 编译器内部生成的 thunk 代码(伪代码)
void Derive::g1_thunk(Derive* this_adjusted) {
    // 调用者传来的 this 指向 Base2 子对象,需减去偏移量得到真正的 Derive*
    Derive* real_this = (Derive*)((char*)this_adjusted - offsetof(Derive, Base2部分));
    real_this->g1();  // 调用真正的 Derive::g1
}

// 因此在 Base2 的虚表中,g1 槽位存的是 thunk 地址,而非直接存 Derive::g1
// 虚表2: [ &thunk_g1, &Base2::g2, 0 ]

对于 Base2 中未被重写的虚函数(如 g2),Base2 的虚表仍保留原始地址,调用时 this 不需要调整(因为它只访问 Base2 自己的成员)。

注意:这就是为什么 Base1* 和 Derive* 指向同一地址,但 Base2* 指向偏移后的地址。强制转换时编译器会自动加上偏移量。

2.7 虚继承(菱形继承)下的虚表结构

虚继承是为了解决菱形继承的数据冗余问题,但这让虚表变得更加复杂。

class Person {
public:
    virtual void speak() { cout << "Person"; }
    int _age;
};

class Student : virtual public Person {
public:
    virtual void speak() { cout << "Student"; }
    virtual void study() { cout << "study"; }
    int _grade;
};

class Teacher : virtual public Person {
public:
    virtual void speak() { cout << "Teacher"; }
    virtual void teach() { cout << "teach"; }
    int _salary;
};

class Assistant : public Student, public Teacher {
public:
    virtual void speak() { cout << "Assistant"; }
    virtual void assist() { cout << "assist"; }
    int _hours;
};

虚继承下 Assistant 对象的内存布局(简化):

┌──────────────────────────┐
│ _vfptr_Student 部分       │ → Student 的虚表(含自己的 + 虚基类偏移信息)
│ Student 的成员 (_grade)   │
├──────────────────────────┤
│ _vfptr_Teacher 部分       │ → Teacher 的虚表(含自己的 + 虚基类偏移信息)
│ Teacher 的成员 (_salary)  │
├──────────────────────────┤
│ Assistant 自己的成员      │
├──────────────────────────┤
│ _vfptr_Person 部分        │ → Person 的虚表(只有一份!)
│ Person 的成员 (_age)      │
└──────────────────────────┘

虚继承虚表的特殊之处:

虚基类 Person 被放到对象的最后面,所有中间类共享同一份。Student 和 Teacher 的虚表中,除了虚函数地址外,还存储了到虚基类 Person 的偏移量(vbptr 或虚表内直接记录偏移)。虚基类的虚表指针也在 Person 子对象中,指向 Person 的虚表。最派生类(Assistant)负责构造虚基类 Person,中间层(Student/Teacher)对 Person 的构造被编译器跳过。访问 Person 的成员时,需要通过偏移量间接计算地址(多一次间接寻址),因此虚继承有轻微性能开销。

2.8 虚函数表的存储位置——实验验证

class Base {
public:
    virtual void func1() { cout << "Base::func1" << endl; }
    virtual void func2() { cout << "Base::func2" << endl; }
    void normalFunc() { cout << "Base::normalFunc" << endl; }
protected:
    int a = 1;
};

int main() {
    int stackVar = 0;                             // 栈
    static int staticVar = 1;                     // 静态区(.data)
    int* heapVar = new int;                       // 堆
    const char* rodataVar = "hello world";         // 常量区(.rdata)

    Base b;
    Base* p = &b;

    printf("栈变量地址:      %p\n", &stackVar);
    printf("静态区变量地址:  %p\n", &staticVar);
    printf("堆变量地址:      %p\n", heapVar);
    printf("常量区变量地址:  %p\n", rodataVar);
    printf("虚表地址:        %p\n", *(void**)p);    // 解引用 _vfptr
    printf("虚函数func1地址: %p\n", (void*)&Base::func1);
    printf("普通函数地址:    %p\n", (void*)&Base::normalFunc);

    delete heapVar;
    return 0;
}

输出结果显示:虚表地址与常量区变量地址最接近(在同一段内),虚函数地址与普通函数地址接近(同在 .text 代码段)。

核心结论:虚函数表存储在常量区(只读数据段 .rdata),虚函数本身存储在代码段(.text),_vfptr 存储在对象内存中(可能是栈、堆或静态区)。


第三部分:动态绑定与静态绑定

3.1 定义

静态绑定:编译时确定函数地址,生成直接 call 指令。

动态绑定:运行时通过虚表查找函数地址,生成间接 call 指令。

3.2 反汇编视角

静态绑定:

struct B {
    void normalFunc() { cout << "normal"; }
};

B b;
b.normalFunc();
// 反汇编:
// call  B::normalFunc    ; 直接 call 固定地址,编译器在编译时已确定

动态绑定:

struct A {
    virtual void vfunc() { cout << "virtual"; }
};

A* a = new A;
a->vfunc();
// 反汇编(x64):
// mov  rax, [a]          ; 1. 取对象地址
// mov  rbx, [rax]        ; 2. 取 _vfptr(虚表地址)
// mov  rcx, rax          ; 3. 将 this 传入 rcx(this call 约定)
// call [rbx]             ; 4. 通过虚表间接调用(call 一个内存地址,而非立即数)

静态绑定生成的 call 后面跟的是一个立即数(编译时确定的绝对地址)。动态绑定生成的 call 后面跟的是一个寄存器间接寻址(运行时才从虚表中取出)。指令长度不同:静态 call 是 5 字节(E8 + 4字节偏移),动态 call 是 2-3 字节(FF + ModRM)。

注意:即使通过对象(非指针/引用)调用虚函数,编译器也会优化为静态绑定。因为此时对象类型确定,无需查表。例如 Dog d; d.speak(); 编译后是直接 call Dog::speak。

3.3 什么情况触发动态绑定

调用方式 绑定类型 说明
obj.func() 静态绑定 对象类型确定,编译器直接 call
ptr->func() 动态绑定(若 func 是虚函数) 指针可能指向任何子类对象
ref.func() 动态绑定(若 func 是虚函数) 引用本质是指针
this->func() 动态绑定(若 func 是虚函数) 成员函数内部通过 this 调用
A::func() 静态绑定 显式指定作用域,绕过虚表

第四部分:深入细节与常见问题

4.1 构造函数为什么不能是虚函数?

从语义看:虚函数需要对象已存在才能通过虚表调用,但构造函数的作用正是创建对象——这是一个先有鸡还是先有蛋的问题。

从实现看:构造函数中,编译器会先将 _vfptr 赋值为当前类的虚表地址,然后执行构造函数体。如果子类构造中调用父类构造,_vfptr 会先指向父类虚表,父类构造完成后才更新为子类虚表。构造期间虚表是逐步更新的,不可能通过虚表找到"还未设好"的函数。

4.2 析构函数为什么应该是虚函数?

class Base {
public:
    ~Base() { cout << "~Base"; }       // 不是虚函数
};

class Derive : public Base {
public:
    ~Derive() { cout << "~Derive"; }   // 也非虚
    int* data = new int[100];          // 动态分配的成员
};

int main() {
    Base* p = new Derive();
    delete p;  // 只调用了 ~Base(),泄漏了 data 指向的 100 个 int!
}

delete p 时,p 的静态类型是 Base*,编译器按 Base 类型调用析构。如果 ~Base() 不是虚函数,就不会查虚表,也就调用不到 ~Derive()。

加上 virtual ~Base() 后,delete p 会查虚表找到 ~Derive(),执行完毕后自动调用 ~Base(),完整析构。

核心结论:有虚函数的类(即可能作为父类的类),析构函数必须写 virtual;无虚函数的类不要写 virtual 析构(节省一个虚表指针的空间)。

4.3 静态成员函数为什么不能是虚函数?

静态成员函数没有 this 指针,而虚函数调用的核心路径是:this -> _vfptr -> 虚表 -> 函数地址。没有 this 就无法找到 _vfptr,更无法查虚表。

从逻辑上说,静态函数属于类本身而非对象,不存在"根据对象类型动态选择"的场景。

4.4 虚函数与 inline

可以同时写 virtual 和 inline,编译器不会报错。但实际上:

当通过指针/引用多态调用时,inline 无效(必须查虚表,无法展开)。当通过对象直接调用时,编译器可以忽略 virtual 做静态绑定,此时 inline 可以生效。

注意:虚函数的 inline 只在非多态调用场景(对象直接调用、显式作用域调用)中有意义。

4.5 虚函数表末尾的 0x00000000

某些编译器(如较老版本的 MSVC)会在虚表末尾放置一个空指针作为结束标记。它的作用是:

标记虚表结束,方便调试器识别虚表边界。在 RTTI 实现中作为 type_info 的定位辅助。

但这不是 C++ 标准规定的行为,不同编译器实现不同。新版 VS 和 GCC 可能没有这个标记。

4.6 RTTI 与虚表的关系

虚表不仅存储函数指针,在虚表的前面(负偏移)还存储了类型信息:

虚表完整布局(MSVC):
┌──────────────────┐  ← 虚表指针 _vfptr 指向这里
│ [-1] &type_info  │  → 指向 type_info 结构(RTTI 信息)
├──────────────────┤
│ [0]  &func1      │  → 第一个虚函数
│ [1]  &func2      │  → 第二个虚函数
│ ...              │
│ [n]  0x00000000  │  → 结束标记(部分编译器)
└──────────────────┘

这就是为什么 dynamic_cast 和 typeid 能工作——它们通过 _vfptr 找到虚表,再通过虚表负偏移找到 type_info。如果一个类没有虚函数,就没有虚表,dynamic_cast 和 typeid 也就无法正常工作。


第五部分:完整验证代码

以下代码可以打印出类对象的虚表内容,验证上述所有理论(仅限 MSVC 或兼容编译器):

#include <iostream>
using namespace std;

class Base {
public:
    virtual void f1() { cout << "Base::f1" << endl; }
    virtual void f2() { cout << "Base::f2" << endl; }
    virtual void f3() { cout << "Base::f3" << endl; }
    int _a = 10;
};

class Derive : public Base {
public:
    virtual void f1() { cout << "Derive::f1" << endl; }   // 重写 f1
    virtual void f2() { cout << "Derive::f2" << endl; }   // 重写 f2
    virtual void f4() { cout << "Derive::f4" << endl; }   // 新增 f4
    int _b = 20;
};

// 通过函数指针调用虚表
typedef void(*VFunc)();

void printVTable(const char* className, void** vtable) {
    cout << className << " 虚表内容:" << endl;
    for (int i = 0; vtable[i] != nullptr; i++) {
        cout << "  [" << i << "] " << vtable[i] << " -> ";
        ((VFunc)vtable[i])();  // 实际调用,观察输出
    }
    cout << "  [结束标记] " << vtable[3] << endl;
    cout << endl;
}

int main() {
    Base b;
    Derive d;

    // 取对象首地址的前 8 字节(64位)= _vfptr
    void** baseVTable = *(void***)&b;
    void** deriveVTable = *(void***)&d;

    printVTable("Base", baseVTable);
    printVTable("Derive", deriveVTable);

    // 多态调用验证
    Base* p = &d;
    cout << "多态调用 p->f1(): ";
    p->f1();  // 应输出 Derive::f1

    return 0;
}

预期输出分析:

Base 虚表:[0]=&Base::f1, [1]=&Base::f2, [2]=&Base::f3, [3]=nullptr

Derive 虚表:[0]=&Derive::f1, [1]=&Derive::f2, [2]=&Base::f3, [3]=&Derive::f4

Derive::f1 和 Derive::f2 覆盖了 Base 的对应槽位;Base::f3 未被重写,保留;Derive::f4 新增,追加到末尾。


总结

本文从五个维度深入剖析了 C++ 多态的本质和虚函数表机制:

第一部分:多态的本质——静态 vs 动态、值/指针/引用三种传递方式、重写的本质、this 指针的真相。

第二部分:虚函数表深度解析——结构、生成三步走、单继承/多继承/虚继承下的内存布局、thunk 与 this 调整。

第三部分:动态绑定与静态绑定——反汇编区别、触发条件。

第四部分:深入细节——构造函数不能虚、析构函数应该虚、静态函数不能虚、inline 与虚函数、虚表结束标记、RTTI。

第五部分:完整验证代码——通过函数指针遍历虚表。

核心结论:多态的全部魔法,归结为一句话:编译时安插虚表指针,运行时通过两级间接寻址查虚函数表。this 指针贯穿这一链路,是连接对象、虚表、虚函数的唯一桥梁。理解 this -> _vfptr -> vtable -> func 这条调用链路,就理解了多态的全部。

更多推荐