C++ 的多态,特别是运行时多态,其底层核心机制就是虚函数表(Virtual Table,简称 vtable)和虚函数指针(Virtual Pointer,简称 vptr)
虚函数表位于只读数据段(.rodata),也就是C++内存模型中的常量区,虚函数代码则位于代码段(.text ),也就是C++内存模型中的代码区。
核心概念:vtable 与 vptr
-
虚函数表 (vtable):可以理解为一个“函数指针数组”。编译器会为每一个包含虚函数的类(或从包含虚函数的类派生的类)生成一个唯一的虚函数表。这个表里按固定顺序存储了该类所有虚函数的地址。
-
虚函数指针 (vptr):每个包含虚函数的类对象在实例化时,都会被编译器隐式地添加一个 vptr 指针。这个指针指向该对象所属类的虚函数表。
+==============================================================================+
| 进程虚拟内存地址空间(简图) |
+==============================================================================+
| |
| 【栈区 (Stack)】 ← 存储局部变量、函数参数、返回地址 |
| ┌───────────────────────┐ |
| │ Base* p; │ ← 基类指针(在栈上),指向堆上的派生类对象 |
| │ int local_var; │ |
| └───────────────────────┘ |
| │ |
| │ (指针指向堆区) |
| ▼ |
| 【堆区 (Heap)】 ← 动态分配的内存(new / malloc) |
| ┌───────────────────────────────────────────────┐ |
| │ 对象实例 (Derived 对象) │ |
| │ +-----------------------------------------+ │ |
| │ | vptr (虚函数表指针) ———————————————┼──┼───────┐ |
| │ +-----------------------------------------+ │ │ |
| │ | 成员变量 (base_val, derived_val...) │ │ │ |
| │ +-----------------------------------------+ │ │ |
| └───────────────────────────────────────────────┘ │ |
| │ |
| 【读写段 (.data / .bss)】 ← 全局变量、静态变量 │ |
| ┌───────────────────────┐ │ |
| │ static int global; │ │ |
| └───────────────────────┘ │ |
| │ |
| 【只读数据段 (.rodata)】 ← 虚函数表、常量字符串等 │ |
| ┌──────────────────────────────────────────────┐ │ |
| │ 虚函数表 (vtable for Derived) │ │ |
| │ +----------------------------------------+ │ │ |
| │ | 0x401230 → &Derived::func1 │ │ │ |
| │ | 0x401280 → &Derived::func2 │ │ │ |
| │ | 0x401350 → &Derived::newFunc │ │ │ |
| │ +----------------------------------------+ │ │ |
| └──────────────────────────────────────────────┘ │ |
| ▲ │ |
| └─────────────────────────────────────────┘ |
| (vptr 指向 vtable 起始地址) |
| |
| 【代码段 (.text)】 ← 存放函数的机器码(可执行指令) |
| ┌─────────────────────────────────────────────┐ |
| │ void Derived::func1() { ... } │ |
| │ void Derived::func2() { ... } │ |
| │ void Base::func1() { ... } │ |
| │ ... │ |
| └─────────────────────────────────────────────┘ |
| |
+==============================================================================+
当你通过基类指针或引用调用一个虚函数时(这是多态发生的三个必要条件之一),编译器会执行以下步骤:
-
获取 vptr:程序运行时,从对象的内存中取出其 vptr 指针。
-
索引 vtable:通过 vptr 找到对应的虚函数表。
-
调用函数:根据虚函数在表中的固定偏移位置,找到正确的函数地址并执行。
由于这个查找和调用的过程发生在程序运行时,而非编译时,因此被称为动态绑定(Dynamic Binding)或迟后联编(Late Binding)。正是这个机制,使得程序能根据指针或引用所指向的实际对象类型来调用正确的函数版本,实现“一个接口,多种形态”的多态效果。
虚函数表的结构会随继承关系的复杂程度而变化。
-
单继承:派生类会继承基类的虚函数表。如果派生类重写(Override) 了基类的某个虚函数,编译器会用派生类自己的函数地址覆盖虚函数表中对应位置的基类函数地址。派生类新增的虚函数则会被添加到该表的末尾。
-
多重继承:情况更复杂。派生类对象会为每个含有虚函数的基类维护一个独立的虚函数表指针(vptr)。其内存布局通常如下:
Derived 对象内存布局 (多重继承自 Base1, Base2): +-------------------+ | vptr for Base1 | ----> 指向与Base1相关的vtable (包含Derived重写的Base1虚函数及新增函数) +-------------------+ | Base1 的成员变量 | +-------------------+ | vptr for Base2 | ----> 指向与Base2相关的vtable (包含Derived重写的Base2虚函数) +-------------------+ | Base2 的成员变量 | +-------------------+ | Derived 的成员变量| +-------------------+这种布局下,为了实现多态,编译器需要进行this指针调整,以确保在通过不同基类指针调用时,能正确访问到派生类对象的起始地址。
注意事项与性能考量
-
内存与性能开销:使用虚函数会带来一定代价。每个包含虚函数的对象会因 vptr 而增加一个指针大小的内存(32位系统4字节,64位系统8字节)。同时,由于调用时需要间接寻址,虚函数调用比普通函数调用略慢,且无法被内联展开。
-
构造函数与 vptr:在对象的构造函数执行期间,vptr 指向的是当前正在构造的类的虚函数表。因此,绝不要在构造函数或析构函数中调用虚函数,此时调用不会产生多态效果,可能引发未定义行为。
-
存储位置:虚函数表本身是编译器在编译期生成的静态数据,通常存储在程序的只读数据段(如 .rodata) 中。
-
标准未规定实现细节:C++ 标准只规定了虚函数的行为,并没有规定必须用 vtable/vptr 实现。但几乎所有主流编译器(如 GCC、Clang、MSVC)都采用了这一高效且成熟的方案。
实例一:单继承 + 重写(最基础的多态)
#include <iostream>
using namespace std;
class Base {
public:
virtual void func1() { cout << "Base::func1" << endl; }
virtual void func2() { cout << "Base::func2" << endl; }
};
class Derive : public Base {
public:
// 重写 func1
virtual void func1() override { cout << "Derive::func1" << endl; }
// 没有重写 func2
};
int main() {
Base* p = new Derive(); // 基类指针指向派生类对象
p->func1(); // 输出:Derive::func1
p->func2(); // 输出:Base::func2
delete p;
return 0;
}
内存布局:
-
编译器为
Base生成一张表:[ &Base::func1, &Base::func2 ] -
编译器为
Derive生成另一张表:[ &Derive::func1, &Base::func2 ](因为func1被覆盖,所以替换了地址;func2没变,沿用基类地址) -
Derive对象中的 vptr 指向 Derive自己的表。 -
调用
p->func1()时:取出vptr → 找到表 → 取第1个地址 → 执行Derive::func1。
实例二:单继承 + 新增虚函数(表是如何扩展的)
如果派生类不仅有重写,还新增了全新的虚函数,它会放在表末尾。
class Base {
public:
virtual void f() { cout << "Base::f" << endl; }
};
class Derive : public Base {
public:
virtual void f() override { cout << "Derive::f" << endl; } // 覆盖位置0
virtual void newFunc() { cout << "Derive::newFunc" << endl; } // 新增
};
int main() {
Base* p = new Derive();
// p->newFunc(); // 编译报错!基类指针看不到新增函数
Derive* q = new Derive();
q->newFunc(); // 输出:Derive::newFunc
// 甚至可以用内存地址强行验证(仅作演示)
// 表的布局:[ &Derive::f, &Derive::newFunc ]
return 0;
}
关键点:
-
Derive的虚函数表大小为 2(第0位是重写的f,第1位是新增的newFunc)。 -
基类指针
p只知道表的前1位(sizeof(Base)的虚函数范围),所以无法访问新函数。这完美解释了为什么基类指针无法调用派生类新增函数。
实例三:多重继承(最坑、最精彩的“多个表”)
多重继承下,派生类会有多个 vptr,分别指向不同的虚函数表。
class Base1 {
public:
virtual void f1() { cout << "Base1::f1" << endl; }
};
class Base2 {
public:
virtual void f2() { cout << "Base2::f2" << endl; }
};
class Derive : public Base1, public Base2 {
public:
virtual void f1() override { cout << "Derive::f1" << endl; } // 覆盖Base1的
virtual void f2() override { cout << "Derive::f2" << endl; } // 覆盖Base2的
virtual void f3() { cout << "Derive::f3" << endl; } // 新增
};
int main() {
Derive d;
Base1* p1 = &d;
Base2* p2 = &d;
p1->f1(); // 输出 Derive::f1
p2->f2(); // 输出 Derive::f2
return 0;
}
内存布局:
Derive 对象 d 的内存起始地址:
+-----------------------+
| vptr for Base1 | ------> 指向 表1: [ &Derive::f1, &Derive::f3 ] (注意,新增的f3跟在了第一个基类表后面)
+-----------------------+
| Base1 的成员变量 |
+-----------------------+
| vptr for Base2 | ------> 指向 表2: [ &Derive::f2 ]
+-----------------------+
| Base2 的成员变量 |
+-----------------------+
编译器做的“隐式手术”(this指针调整):
当你写
Base2* p2 = &d;
时,编译器偷偷把指针偏移到了对象中 Base2 子对象的起始位置(即跳过了前面的 vptr for Base1 和 Base1成员)。当调用 p2->f2() 时,为了正确访问到完整的 Derive 对象,编译器在调用前会悄悄把 this 指针再偏移回去。这就是为什么多重继承下的强制转换是有开销的。
实例四:构造函数与析构函数中的虚函数(终极陷阱)
这是面试高频坑题:在构造函数里调用虚函数,多态会“失效”。
class Base {
public:
Base() {
func(); // 注意:这里调用了虚函数
}
virtual void func() { cout << "Base::func" << endl; }
};
class Derive : public Base {
public:
virtual void func() override { cout << "Derive::func" << endl; }
};
int main() {
Derive d; // 猜猜输出什么?
return 0;
}
输出结果: Base::func (而不是 Derive::func)
原理解析:
-
构造顺序:先构造基类,再构造派生类。
-
当执行
Base的构造函数时,Derive还没开始构造,此时对象中的 vptr 暂时指向Base的虚函数表! -
等基类构造完毕,轮到
Derive构造函数执行时,vptr 才会被重新赋值,改为指向Derive的表。 -
因此,构造函数执行期间,对象的类型被视为当前正在构造的类,虚函数不会向下调用。析构函数同理(析构时,先切回基类vptr,再执行基类析构代码)。 一图总结 (vptr 与 vtable 完整交互)
把实例1和实例4的逻辑合并成一张逻辑图:
[Base类] [Derive类]
vtable_Base: vtable_Derive:
+----------------+ +----------------+
| &Base::func1 | <---(继承)--- | &Derive::func1 | (覆盖)
+----------------+ +----------------+
| &Base::func2 | <---(继承)--- | &Base::func2 | (未覆盖,沿用)
+----------------+ +----------------+
| &Derive::new | (新增,尾部扩展)
+----------------+
对象内存 (Derive d):
[ vptr ] ---(构造初期指向Base表,构造完成后指向Derive表) --> 上表
对象切片(Object Slicing)
Base* p = new Derived(); // 正常多态
Base b = *p; // 切片发生
// p->func() -> Derived
// b.func() -> Base
父类指针指向子类,是地址的映射(看外面的壳,指向里面的核),对象本身没变;子类赋值给父类实例,是内存的拷贝(把核切掉,只拿壳),对象本身被裁切。
#include <iostream>
using namespace std;
class Base {
public:
virtual void show() { cout << "我是 Base" << endl; }
int base_val = 1;
};
class Derived : public Base {
public:
virtual void show() override { cout << "我是 Derived" << endl; }
int derived_val = 2; // 子类独有的成员
};
int main() {
Derived d;
d.base_val = 100;
d.derived_val = 200;
// ---------- 场景1:父类指针指向子类 ----------
Base* p = &d;
cout << "指针指向的对象大小(逻辑上): " << sizeof(*p) << " (但实际是Derived)" << endl;
p->show(); // 输出:我是 Derived (多态生效)
// p->derived_val; // 编译报错!指针类型限制,看不到子类成员,但内存中实际存在
// ---------- 场景2:子类赋值给父类实例 ----------
Base b = d; // 切片发生!
cout << "赋值后对象大小: " << sizeof(b) << endl; // 只有 Base 的大小
b.show(); // 输出:我是 Base (多态失效!)
cout << b.base_val; // 输出:100 (基类成员拷贝过来了)
// cout << b.derived_val; // 编译报错!derived_val 被彻底切掉了,对象 b 里根本没有它
// 验证内存地址:
cout << "&d = " << &d << ", p = " << p << endl; // 地址相同
cout << "&b = " << &b << endl; // 地址完全不同(独立的新对象)
return 0;
}
虚函数表(vptr)的“灵魂突变”
| 场景 | 对象里的 vptr 指向哪里? | 调用 show() 的结果 |
|---|---|---|
指针 p 指向 d | p 指向的内存中,vptr 依然是 Derived 的虚函数表(因为构造时就是子类)。 | 动态绑定 → 执行 Derived::show() |
实例 b = d | b 是刚刚新构造的 Base 对象,它的 vptr 在构造时被设置为 Base 的虚函数表。 | 静态绑定(或基于自身类型)→ 执行 Base::show() |
结论:赋值操作 不会复制 vptr!它只复制成员变量,新对象的类型就是基类类型。子类对象 d (Derived) 内存:
+----------------------+
| vptr (指向Derived表) | <--- 父类指针 p 指着这里
+----------------------+
| base_val = 100 |
+----------------------+
| derived_val = 200 | <--- 这块内存存在,但被基类指针“屏蔽”了
+----------------------+
执行 Base b = d; 后,新对象 b (Base) 内存:
+----------------------+
| vptr (指向Base表) | <--- 全新的 vptr,切断了与子类的联系
+----------------------+
| base_val = 100 | <--- 只拷贝了这一块
+----------------------+
(derived_val 被彻底丢弃,没有分配内存)
// 错误写法(值传递)
void func_by_value(Base obj) {
obj.show(); // 无论传什么进来,永远输出 "我是 Base"(因为切片了)
}
// 正确写法(指针或引用传递)
void func_by_ptr(Base* obj) {
obj->show(); // 多态生效,输出实际类型
}
void func_by_ref(Base& obj) {
obj.show(); // 多态生效,输出实际类型
}
int main() {
Derived d;
func_by_value(d); // 传入时发生切片,多态失效
func_by_ptr(&d); // 传入地址,多态生效
func_by_ref(d); // 传入引用(本质是指针封装),多态生效
}
实战避坑指南
-
别用基类指针去 delete 派生类对象,除非基类析构函数是
virtual,否则只会释放基类部分,造成内存泄漏(这是多态最经典的坑)。 -
多重继承下慎用类型转换,
dynamic_cast开销比static_cast大,因为它会去查虚函数表(RTTI信息也存表附近)。 -
性能代价:虚函数调用比普通函数慢一点(多了一次间接寻址),且无法内联。
-
如果你想保留多态,永远使用指针(
*)或引用(&),绝不使用值传递(值语义)来接收父类对象。 -
STL 容器里的“切片”陷阱:
vector<Base>中push_back(Derived())会发生切片,存入的永远只是Base部分。如果需要存放多态对象,必须使用vector<Base*>或vector<unique_ptr<Base>>。 -
赋值运算符同样危险:
Base b; b = d;同理,只拷贝基类部分,不改变b的类型。
3355

被折叠的 条评论
为什么被折叠?



