前言
很多 C++ 开发者都觉得多态很简单:基类加个 virtual,派生类重写一下,用基类指针调用就能自动执行派生类版本。
直到被问到:
虚函数底层到底怎么实现的?
vtable 和 vptr 是什么?
为什么构造函数里调用虚函数永远是基类版本?
才发现自己只是"会用",却从未真正理解其原理。
本文将从多态的本质出发,层层递进,详细讲解 C++ 虚函数的完整实现机制 ------ vtable(虚表) + vptr(虚指针),并深入构造函数、析构函数、多继承等关键细节。
读完这篇,你会对"运行时多态"有一个从"知道怎么用"到"彻底懂为什么"的质的飞跃。
首先,我们先要学习:
一、为什么需要多态?
多态的字面意思是"多种形态"。在C++中,它的核心思想是:用同一套接口(函数名),让不同类型的对象表现出不同的行为。
C++提供了两种多态:
| 类型 | 绑定时机 | 实现方式 | 场景 |
|---|---|---|---|
| 编译时多态 | 编译期 | 函数重载、模板 | 泛型编程、运算符重载 |
| 运行时多态 | 运行时 | 虚函数 | 面向对象设计、框架/插件 |
这篇文章重点讲运行时多态,因为它是C++最具"面向对象"特色的机制。
没有虚函数时会发生什么?
cpp
class Base {
public:
void show() { std::cout << "Base::show()\n"; }
};
class Derived : public Base {
public:
void show() { std::cout << "Derived::show()\n"; }
};
int main() {
Derived d;
Base* p = &d;// 基类指针指向派生类对象
p->show();
}
输出为:

为什么输出的时Base的版本?
因为编译器在编译期就根据指针的静态类型(Base*)决定了要调用哪个函数------这叫早绑定(静态绑定)。
我们想要的是:指针/引用指向什么对象,就调用什么对象的函数------这就是晚绑定(动态绑定),即运行时多态。
这时候就需要虚函数了:
二、虚函数的语法与基本使用
只需要在基类的函数声明前加上virtual关键字:
cpp
class Base {
public:
virtual void show() { std::cout << "Base::show()\n"; }
virtual ~Base() {}
};
class Derived : public Base {
public:
void show() override
{
std::cout << "Derived::show()\n";
}
};
int main() {
Derived d;
Base* p = &d;// 基类指针指向派生类对象
p->show();
}

现在p->show() 会输出 Derived::show() ------ 实现了运行时多态。
其中:
- virtual 只需在基类中声明一次,派生类自动继承虚属性;
- 派生类可以不重写(保持基类行为),也可以重写(覆盖);
- override 关键字让编译器帮你检查是否真的覆盖了基类虚函数,强烈推荐使用。
现在,我们了解了虚函数的语法与基本使用,接下来学习:
三、虚函数的实现原理:vtable(虚表) + vptr(虚指针)
C++编译器通过两个隐藏的机制实现了动态绑定:
- 虚表(virtual table,vtable)
- 每个含有虚函数的类(或其派生类),编译器都会在编译期为其生成一张虚函数指针表;
- vtable 是一张数组,里面存放的是虚函数的地址;
- 单继承时:派生类的vtable是基类vtable的"扩展 + 覆盖"版。
- 虚指针(virtual pointer,vptr)
- 每个对象实例在内存中都会有一个隐藏的指针(通常放在对象最前面);
- vptr指向本对象所属类的vtable;
- 对象构造时,构造函数会自动把vptr设置为正确类的vtable。
用一句话总结实现原理:通过对象里的vptr找到类的vtable,再通过vtable中的函数指针间接调用函数->运行时决定调用哪个版本。
接下来我用内存布局图解(单继承)帮你更好理解这一部分:
假设有下面代码:
cpp
class Base {
public:
virtual void f1() {}
virtual void f2() {}
int x;
};
class Derived : public Base {
public:
void f1() override {} // 覆盖
virtual void f3() {} // 新增虚函数
int y;
};
Base对象的内存布局(以32位为例,指针4字节):
bash
[ vptr ] → 指向 Base 的 vtable
[ x ]
Base的vtable(编译器生成):
| 索引 | 函数地址 |
|---|---|
| 0 | Base::f1() |
| 1 | Base::f2() |
Derived对象的内存布局:
bash
[ vptr ] → 指向 Derived 的 vtable
[ x ]
[ y ]
Derived的vtable:
| 索引 | 函数地址 |
|---|---|
| 0 | Derived::f1()(覆盖) |
| 1 | Base::f2()(继承) |
| 2 | Derived::f3()(新增) |
调用过程(Base* p = new Derived(); p->f1();):
1.取 p 的指向对象的vptr (偏移0);
2.沿着vptr找到Derived的vtble;
3.取vtable[0](因为f1是第一个虚函数);
4.跳转到Derived::f1()的地址。
整个过程在运行时完成,与指针的静态类型完全无关。
四、构造函数中vptr的初始化过程
构造函数执行顺序决定了vptr的指向:
cpp
Base::Base() {
// 这里 vptr 已经被设置为 Base::vtable
// 如果在基类构造函数里调用虚函数,调用的是 Base 版本!
}
Derived::Derived() : Base() {
// 基类构造完成后,vptr 被修改为 Derived::vtable
}
重要规则:
- 在基类构造函数中调用虚函数,永远调用基类版本(因为此时 vptr 还没指向派生类 vtable);
- 析构函数顺序相反,先派生类析构,再基类析构。
cpp
class Base {
public:
virtual ~Base() {} // 如果不加 virtual,delete 通过基类指针会只调用 Base 析构,内存泄漏!
};
五、多继承下的虚表
多继承时,对象会有多个 vptr(每个基类一个):
cpp
class Base1 { virtual void f(); };
class Base2 { virtual void g(); };
class Derived : public Base1, public Base2 { ... };
Derived 对象内存布局大致为:
bash
[ vptr1 → Base1::vtable(调整后) ]
[ Base1 的成员 ]
[ vptr2 → Base2::vtable(调整后) ]
[ Base2 的成员 ]
[ Derived 的成员 ]
调用 Base1* p = &d; p->f(); 走 vptr1;
调用 Base2* p = &d; p->g(); 走 vptr2。
菱形继承(钻石问题)必须用虚继承(virtual public),否则会有两份基类子对象和两份 vptr,导致二义性。虚继承会额外生成虚基类指针(vbptr)。(下篇文章详细讲解)
六、纯虚函数与抽象类
cpp
class Abstract {
public:
virtual void pure() = 0; // 纯虚函数
};
- 含有纯虚函数的类称为抽象类,不能实例化。
- 派生类必须实现所有纯虚函数,否则仍是抽象类。
- 纯虚函数在 vtable 中对应位置通常填 nullptr 或特殊的"纯虚函数调用失败"函数(调用会直接抛异常)。
七、性能开销与注意事项
1.空间开销
每个含虚函数的类:一张 vtable(函数数量 × 指针大小)。
每个对象:额外一个 vptr(通常 4/8 字节)。
2.时间开销
普通函数调用:直接跳转。
虚函数调用:两次间接寻址(vptr → vtable → 函数),现代 CPU 预测分支后开销很小,但仍比非虚函数慢一点。
3.常见陷阱
通过基类指针 delete 非虚析构 → 未定义行为。
在构造函数/析构函数中调用虚函数 → 永远调用当前类的版本。
值传递对象(切片)→ 多态失效。
不要在 vtable 里放非虚函数。
(下篇文章详细展开讲解)
总结
编译器为每个有虚函数的类生成 vtable(虚函数地址表),为每个对象偷偷插入 vptr(指向本类 vtable)。调用虚函数时:对象.vptr → vtable[函数索引] → 实际函数地址,从而实现"运行时根据真实类型绑定"。