C++复习,继承与多态
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 这条调用链路,就理解了多态的全部。
更多推荐
所有评论(0)