目录
特性 2:通过基类指针 / 引用调用时,根据对象实际类型执行函数
三、底层原理:虚函数表(vtable)和虚指针(vptr)是如何工作的?
3.2 虚指针(vptr):对象指向 vtable 的 "指针"
8.3 坑点 1:派生类重写时改变参数列表,导致隐藏而非重写

class 卑微码农:
def __init__(self):
self.技能 = ['能读懂十年前祖传代码', '擅长用Ctrl+C/V搭建世界', '信奉"能跑就别动"的玄学']
self.发量 = 100 # 初始发量
self.咖啡因耐受度 = '极限'
def 修Bug(self, bug):
try:
# 试图用玄学解决问题
if bug.严重程度 == '离谱':
print("这一定是环境问题!")
else:
print("让我看看是谁又没写注释...哦,是我自己。")
except Exception as e:
# 如果try块都救不了,那就...
print("重启一下试试?")
self.发量 -= 1 # 每解决一个bug,头发-1
# 实例化一个我
我 = 卑微码农()
一、开篇:为什么基类指针总是 "认不出" 派生类对象?
刚学 C++ 那会,我写过这样一段代码:定义了一个Shape基类和Circle派生类,想用基类指针调用派生类的draw方法,结果却始终执行基类的版本。当时对着屏幕一脸懵:明明指针指向的是Circle对象,为什么它就 "认不出来" 呢?
#include <iostream>
using namespace std;
class Shape {
public:
void draw() { // 普通成员函数
cout << "画图形" << endl;
}
};
class Circle : public Shape {
public:
void draw() { // 派生类同名函数
cout << "画圆形" << endl;
}
};
int main() {
Shape* ptr = new Circle(); // 基类指针指向派生类对象
ptr->draw(); // 输出"画图形",而不是预期的"画圆形"
delete ptr;
return 0;
}
后来才知道,这就是缺少虚函数导致的 —— 没有virtual关键字,C++ 编译器会根据 "指针类型" 而不是 "对象实际类型" 来调用函数。而虚函数的作用,就是让指针 "看穿" 自己指向的真实对象,执行正确的函数版本。

如果你也遇到过这些困惑:
- 什么是虚函数?它和普通函数有什么本质区别?
- 为什么加上
virtual关键字就能实现多态?底层原理是什么? - 虚析构函数为什么能避免内存泄漏?
- 纯虚函数和抽象类到底有什么用?
本文会从实际问题出发,用 18 个代码示例 + 内存结构图,帮你彻底搞懂虚函数的底层机制、使用场景和避坑指南。看完你会发现:虚函数不是语法糖,而是 C++ 多态的 "灵魂引擎"。
二、虚函数基础:加个virtual,世界都不一样了

解决开篇问题的方法很简单:在基类的draw函数前加virtual关键字,让它成为虚函数。
class Shape {
public:
virtual void draw() { // 虚函数
cout << "画图形" << endl;
}
};
class Circle : public Shape {
public:
void draw() { // 派生类重写虚函数
cout << "画圆形" << endl;
}
};
int main() {
Shape* ptr = new Circle();
ptr->draw(); // 输出"画圆形",符合预期
delete ptr;
return 0;
}
仅仅一个virtual,就让基类指针 "认对" 了对象 —— 这就是虚函数的魔力。但背后的逻辑远不止 "加个关键字" 这么简单。
2.1 虚函数的核心特性
特性 1:派生类可以 "重写"(Override)虚函数
"重写" 指的是派生类定义一个与基类虚函数同名、同参数、同返回值的函数(协变返回值除外),从而覆盖基类的实现。
class Shape {
public:
virtual void draw() { cout << "Shape::draw" << endl; }
virtual int getArea(int a) { return a * a; } // 带参数的虚函数
};
class Circle : public Shape {
public:
void draw() override { // 用override显式声明重写(C++11)
cout << "Circle::draw" << endl;
}
int getArea(int r) override { // 重写带参数的虚函数
return 3 * r * r; // 简化的圆面积计算
}
};
建议:重写时加上
override关键字,编译器会检查是否真的重写了基类虚函数,避免拼写错误或参数不匹配导致的隐藏(比如把draw写成drwa)。
特性 2:通过基类指针 / 引用调用时,根据对象实际类型执行函数
这是虚函数实现多态的核心:调用哪个版本的函数,取决于指针 / 引用指向的 "对象类型",而不是指针 / 引用本身的类型。
void render(Shape& shape) { // 基类引用
shape.draw(); // 调用哪个draw?取决于shape的实际类型
}
int main() {
Shape s;
Circle c;
render(s); // 传入Shape对象,输出"Shape::draw"
render(c); // 传入Circle对象,输出"Circle::draw"
return 0;
}
特性 3:虚函数具有 "传递性"
基类的虚函数被声明后,派生类中重写的函数自动成为虚函数(即使不加virtual关键字)。
class Shape {
public:
virtual void draw() { cout << "Shape" << endl; }
};
class Circle : public Shape {
public:
void draw() { cout << "Circle" << endl; } // 自动为虚函数
};
class RedCircle : public Circle {
public:
void draw() { cout << "RedCircle" << endl; } // 继承虚函数特性
};
int main() {
Shape* ptr = new RedCircle();
ptr->draw(); // 输出"RedCircle",多态传递生效
delete ptr;
return 0;
}
2.2 虚函数 vs 普通函数:调用机制的本质区别
普通函数的调用在编译期就确定了(静态绑定),而虚函数的调用在运行期才确定(动态绑定)。
- 普通函数:编译器根据 "指针 / 引用的类型" 找到函数地址,直接调用;
- 虚函数:编译器在运行时根据 "对象的实际类型" 找到函数地址,动态调用。
用代码验证:
class Base {
public:
void normalFunc() { cout << "Base普通函数" << endl; } // 静态绑定
virtual void virtualFunc() { cout << "Base虚函数" << endl; } // 动态绑定
};
class Derived : public Base {
public:
void normalFunc() { cout << "Derived普通函数" << endl; }
void virtualFunc() { cout << "Derived虚函数" << endl; }
};
int main() {
Base* ptr = new Derived();
ptr->normalFunc(); // 静态绑定:根据ptr类型(Base)调用Base版本
ptr->virtualFunc(); // 动态绑定:根据对象类型(Derived)调用Derived版本
delete ptr;
return 0;
}
运行结果:
Base普通函数
Derived虚函数
三、底层原理:虚函数表(vtable)和虚指针(vptr)是如何工作的?

虚函数的动态绑定不是凭空实现的,背后依赖两个关键结构:虚函数表(vtable) 和虚指针(vptr)。理解这两个概念,才算真正懂了虚函数。
3.1 虚函数表(vtable):类的 "函数地址目录"
每个包含虚函数的类(或其派生类)会有一个虚函数表,本质是一个存储虚函数地址的数组。这个表在编译期创建,每个类只有一份,所有对象共享。
- 基类有自己的 vtable,存储基类的虚函数地址;
- 派生类如果重写了基类的虚函数,vtable 中会用派生类的函数地址覆盖对应位置;
- 派生类新增的虚函数,会被添加到 vtable 的末尾。
用Shape和Circle举例,vtable 结构如下:
Shape类的vtable:
[0] → &Shape::draw
Circle类的vtable(继承自Shape):
[0] → &Circle::draw // 覆盖基类的draw
3.2 虚指针(vptr):对象指向 vtable 的 "指针"
每个包含虚函数的对象,都会有一个虚指针(vptr),指向所属类的 vtable。vptr 在对象创建时(构造函数执行期间)被初始化,存储 vtable 的地址。
对象的内存布局(简化):
Shape对象:
+----------+
| vptr | → 指向Shape的vtable
+----------+
| 其他成员变量 |
+----------+
Circle对象(继承Shape):
+----------+
| vptr | → 指向Circle的vtable
+----------+
| 从Shape继承的成员变量 |
+----------+
| Circle自己的成员变量 |
+----------+
3.3 动态绑定的完整流程:从对象到函数调用
当我们用基类指针调用虚函数时,步骤如下:
- 通过基类指针找到对象的 vptr(对象内存的第一个位置);
- 通过 vptr 找到该对象所属类的 vtable;
- 在 vtable 中找到对应虚函数的地址(按索引);
- 调用该地址的函数。
用代码对应的流程图:
Shape* ptr = new Circle(); // ptr指向Circle对象
ptr->draw(); // 调用流程:
// 1. 取对象的vptr:*ptr → vptr(指向Circle的vtable)
// 2. 查vtable的第0项:Circle::draw的地址
// 3. 调用该地址的函数 → 输出"画圆形"
3.4 用代码验证 vtable 的存在
我们无法直接访问 vtable,但可以通过指针操作间接证明它的存在:
#include <iostream>
using namespace std;
class Base {
public:
virtual void func1() { cout << "Base::func1" << endl; }
virtual void func2() { cout << "Base::func2" << endl; }
};
class Derived : public Base {
public:
void func1() override { cout << "Derived::func1" << endl; }
virtual void func3() { cout << "Derived::func3" << endl; } // 新增虚函数
};
int main() {
Base* b = new Derived();
// 步骤1:获取对象的vptr(假设vptr是对象的第一个成员)
// 用long*指针指向对象,取第一个值(vptr的值)
long* vptr = (long*)b;
// 步骤2:通过vptr获取vtable(数组)
long* vtable = (long*)*vptr;
// 步骤3:调用vtable中的函数(通过函数指针)
// 定义函数指针类型(无参数无返回值)
typedef void (*Func)();
Func f1 = (Func)vtable[0]; // vtable[0]是func1(已被Derived覆盖)
Func f2 = (Func)vtable[1]; // vtable[1]是func2(基类版本)
Func f3 = (Func)vtable[2]; // vtable[2]是Derived新增的func3
f1(); // 输出"Derived::func1"
f2(); // 输出"Base::func2"
f3(); // 输出"Derived::func3"
delete b;
return 0;
}
这段代码通过指针直接访问 vtable 并调用函数,验证了 vtable 的结构 —— 基类虚函数被覆盖,派生类新增虚函数被添加到表中。
四、虚析构函数:避免多态场景下的内存泄漏

当用基类指针指向派生类对象并删除时,如果析构函数不是虚函数,会导致派生类部分内存泄漏。这是虚函数最容易被忽视的实战场景。
4.1 非虚析构函数的隐患
#include <iostream>
using namespace std;
class Base {
public:
Base() { cout << "Base构造" << endl; }
~Base() { cout << "Base析构" << endl; } // 非虚析构函数
};
class Derived : public Base {
private:
int* data; // 派生类自己的动态内存
public:
Derived() {
data = new int[10];
cout << "Derived构造" << endl;
}
~Derived() {
delete[] data; // 释放动态内存
cout << "Derived析构" << endl;
}
};
int main() {
Base* ptr = new Derived(); // 基类指针指向派生类对象
delete ptr; // 只会调用Base的析构函数,Derived的析构函数不执行
return 0;
}
运行结果:
Base构造
Derived构造
Base析构 // Derived的析构函数未被调用,data内存泄漏!
原因:析构函数不是虚函数时,delete ptr会根据ptr的类型(Base)调用 Base 的析构函数,忽略 Derived 的析构函数,导致派生类中动态分配的内存(如data)无法释放。
4.2 虚析构函数的解决方案
只需把基类的析构函数声明为虚函数,派生类的析构函数会自动成为虚函数:
class Base {
public:
Base() { cout << "Base构造" << endl; }
virtual ~Base() { cout << "Base析构" << endl; } // 虚析构函数
};
// Derived类不变
int main() {
Base* ptr = new Derived();
delete ptr; // 先调用Derived析构,再调用Base析构
return 0;
}
运行结果:
Base构造
Derived构造
Derived析构 // 正确执行,释放data
Base析构
结论:只要类可能被继承,且存在动态内存分配,就应该把析构函数声明为虚函数。
五、纯虚函数与抽象类:定义接口的 "模板"

有时候我们希望基类只定义接口(函数名和参数),而不提供实现,强制派生类自己实现具体逻辑。这时就需要纯虚函数和抽象类。
5.1 纯虚函数:没有实现的虚函数
纯虚函数的定义方式是在虚函数声明后加= 0:
class Shape {
public:
virtual void draw() = 0; // 纯虚函数:只有声明,没有实现
virtual int getArea() = 0;
};
包含纯虚函数的类称为抽象类,抽象类有两个核心特性:
- 不能实例化对象(无法
new Shape()); - 派生类必须重写所有纯虚函数,否则派生类也是抽象类。
5.2 抽象类的实战价值:定义接口规范
抽象类的本质是 "接口",它规定了派生类必须实现的功能,却不限制具体实现方式。比如定义一个 "图形" 接口,强制所有图形实现 "绘制" 和 "计算面积":
// 抽象类(接口)
class Shape {
public:
virtual void draw() = 0; // 必须实现绘制
virtual int getArea() = 0; // 必须实现面积计算
virtual ~Shape() = default; // 虚析构函数(避免泄漏)
};
// 派生类实现接口
class Square : public Shape {
private:
int side;
public:
Square(int s) : side(s) {}
void draw() override {
cout << "绘制正方形" << endl;
}
int getArea() override {
return side * side;
}
};
class Triangle : public Shape {
private:
int base, height;
public:
Triangle(int b, int h) : base(b), height(h) {}
void draw() override {
cout << "绘制三角形" << endl;
}
int getArea() override {
return (base * height) / 2;
}
};
这样,所有继承Shape的类都必须实现draw和getArea,保证了接口的一致性,也方便用基类指针统一管理:
#include <vector>
int main() {
vector<Shape*> shapes;
shapes.push_back(new Square(5));
shapes.push_back(new Triangle(4, 5));
for (auto s : shapes) {
s->draw(); // 多态调用:绘制不同图形
cout << "面积:" << s->getArea() << endl; // 多态调用:计算不同面积
delete s;
}
return 0;
}
运行结果:
绘制正方形
面积:25
绘制三角形
面积:10
5.3 抽象类可以有非纯虚函数
抽象类并非只能有纯虚函数,也可以包含普通成员函数(带实现),供派生类直接使用:
class Shape {
public:
virtual void draw() = 0; // 纯虚函数(接口)
void printArea() { // 普通函数(提供默认实现)
cout << "面积是:" << getArea() << endl; // 调用纯虚函数(多态)
}
virtual int getArea() = 0;
};
class Square : public Shape {
// ... 实现draw和getArea ...
};
int main() {
Shape* s = new Square(5);
s->printArea(); // 调用基类的printArea,内部多态调用getArea
delete s;
return 0;
}
这种设计既规定了接口(纯虚函数),又提供了通用功能(普通函数),是 "模板方法模式" 的典型应用。
六、虚函数的 "禁区":这些情况不能用虚函数

虚函数虽好,但并非万能,以下场景禁止使用虚函数,否则会导致编译错误或未定义行为。
6.1 构造函数不能是虚函数
构造函数的作用是初始化对象,包括设置 vptr。如果构造函数是虚函数,需要 vptr 找到对应的函数,但此时 vptr 还未初始化(鸡生蛋问题),因此 C++ 禁止构造函数为虚函数。
class Base {
public:
virtual Base() {} // 编译报错:构造函数不能是虚函数
};
6.2 静态成员函数不能是虚函数
静态成员函数属于类,不属于对象,没有this指针,而虚函数的调用依赖this指针找到 vptr。因此静态函数不能是虚函数。
class Base {
public:
static virtual void func() {} // 编译报错:静态函数不能是虚函数
};
6.3 内联函数不能是虚函数
内联函数的特点是 "编译期展开",而虚函数需要 "运行期动态绑定",两者矛盾。虽然语法允许virtual inline,但编译器会忽略inline,按普通虚函数处理。
class Base {
public:
virtual inline void func() { // inline被忽略
cout << "Base::func" << endl;
}
};
6.4 友元函数不能是虚函数
友元函数不是类的成员函数,没有this指针,无法访问 vptr,因此不能是虚函数。
class Base {
friend virtual void func(); // 编译报错:友元不能是虚函数
};
七、虚函数的性能开销:该不该用虚函数?

虚函数的动态绑定不是没有代价的,主要开销体现在三个方面:
7.1 内存开销
- 每个包含虚函数的对象会多一个 vptr(4 字节或 8 字节,取决于系统位数);
- 每个类会有一个 vtable(存储虚函数地址,大小为虚函数数量 × 指针大小)。
对于小对象(如只包含几个成员变量),vptr 可能增加 20%-50% 的内存占用;但对于大对象,这点开销通常可以忽略。
7.2 时间开销
虚函数调用比普通函数多了两步操作:
- 通过 vptr 找到 vtable;
- 在 vtable 中查找函数地址。
这会导致虚函数调用比普通函数慢约 10%-20%(实测数据)。但在大多数场景下,这点性能损失远小于代码可读性和可维护性的收益。
7.3 优化建议
- 不需要多态的函数,不要声明为虚函数;
- 类层次结构不要过深(过深的继承会增加 vtable 查找成本);
- 频繁调用的热点函数(如循环内),尽量避免用虚函数(可用模板或函数指针替代)。
八、虚函数常见面试题与坑点解析

8.1 面试题 1:重写、重载、隐藏的区别?
- 重写:派生类重写基类的虚函数,要求函数名、参数、返回值相同(协变除外),依赖虚函数实现多态;
- 重载:同一作用域内的同名函数,参数列表不同,编译期绑定;
- 隐藏:派生类函数与基类同名但不构成重写,派生类函数会隐藏基类函数,调用时需显式指定。
8.2 面试题 2:虚函数表在什么时候创建?存在哪里?
- vtable 在编译期创建,属于类的元信息;
- 存储在只读数据段(.rodata),和常量、代码存放在一起,程序启动时加载到内存。
8.3 坑点 1:派生类重写时改变参数列表,导致隐藏而非重写
class Base {
public:
virtual void func(int a) { cout << "Base::func(int)" << endl; }
};
class Derived : public Base {
public:
void func(double a) { // 参数不同,不构成重写,而是隐藏
cout << "Derived::func(double)" << endl;
}
};
int main() {
Base* ptr = new Derived();
ptr->func(10); // 调用Base::func(int)(因为Derived没有重写)
delete ptr;
return 0;
}
解决:重写时确保参数列表完全相同,并用override关键字检查。
8.4 坑点 2:构造函数中调用虚函数,不会触发多态
class Base {
public:
Base() {
func(); // 构造函数中调用虚函数
}
virtual void func() { cout << "Base::func" << endl; }
};
class Derived : public Base {
public:
void func() override { cout << "Derived::func" << endl; }
};
int main() {
Derived d; // 输出"Base::func",而非"Derived::func"
return 0;
}
原因:构造派生类对象时,先调用基类构造函数,此时派生类部分尚未初始化,vptr 仍指向基类 vtable,因此调用基类版本。永远不要在构造 / 析构函数中调用虚函数。
九、总结:虚函数是 C++ 多态的 "基石"
虚函数的本质是通过 vtable 和 vptr 实现的动态绑定机制,它让 C++ 能够写出 "以不变应万变" 的代码 —— 用基类指针统一管理不同派生类对象,调用时自动匹配正确的实现。
从实战角度看,虚函数的核心价值体现在三个方面:
- 多态性:同一接口,不同实现,简化代码逻辑;
- 接口定义:通过纯虚函数构建抽象类,规范派生类行为;
- 内存安全:虚析构函数避免多态场景下的内存泄漏。
最后,记住虚函数的 "使用三原则":
- 需要多态时才用虚函数,不滥用;
- 基类析构函数必须是虚函数(除非类不被继承);
- 重写时用
override关键字,避免隐藏陷阱。
理解虚函数,你就掌握了 C++ 面向对象编程的 "半壁江山"。下次再遇到基类指针调用函数的场景,不妨想一想:vptr 此刻指向哪个 vtable?要调用的函数地址在 vtable 的第几项?想清楚这些,你写的代码会更健壮、更优雅。






