跳到主要内容C++ 多态核心原理与虚函数表详解 | 极客日志C++算法
C++ 多态核心原理与虚函数表详解
C++ 多态概念,区分静态与动态多态。重点讲解虚函数、override、纯虚函数、抽象类及虚析构函数的作用。深入剖析底层虚函数表(vtable)机制,分析对象切片问题及常见误区。结合图形系统实例说明工程应用,并总结面试高频考点,帮助读者掌握面向对象编程核心思想。
修罗30 浏览 一、什么是多态?
多态,英文是 Polymorphism,字面意思就是'多种形态'。
在 C++ 里,多态指的是:
同一个接口,作用于不同对象时,可以表现出不同的行为。
举个最简单的例子:
- 你定义了一个
speak() 接口
- 猫调用它时发出'喵喵'
- 狗调用它时发出'汪汪'
虽然调用方式一样,但不同对象的执行结果不一样,这就是多态。
二、为什么多态这么重要?
因为多态可以让程序:
- 代码更通用
- 扩展更方便
- 更符合面向对象设计思想
- 降低大量
if...else 或 switch...case 判断
比如你写一个游戏系统:
如果不用多态,你可能会写很多类型判断。
如果用了多态,你只需要统一调用 attack(),具体怎么打,交给各自的类自己决定。
这就是多态真正的价值:面向接口编程,而不是面向具体实现编程。
三、C++ 中的多态分为哪两种?
C++ 的多态,通常分为两大类:
1. 静态多态(编译期多态)
也叫早绑定,编译阶段就已经确定要调用哪个函数。
常见形式:
示例:
#include <iostream>
using namespace std;
void print(int x) {
cout << "int: " << x << endl;
}
void print(double x) {
cout << "double: " << x << endl;
}
int main() {
print(10);
print(3.14);
return 0;
}
这里 print(10) 和 调用哪个函数,编译器在编译时就已经知道了。
print(3.14)
2. 动态多态(运行期多态)
也叫晚绑定,真正调用哪个函数,要到程序运行时才能确定。
- 存在继承关系
- 基类中有虚函数
- 通过基类指针或基类引用调用虚函数
四、先看一个最经典的动态多态例子
#include <iostream>
using namespace std;
class Animal {
public:
virtual void speak() {
cout << "Animal speaks" << endl;
}
};
class Cat : public Animal {
public:
void speak() override {
cout << "Cat says: miao miao" << endl;
}
};
class Dog : public Animal {
public:
void speak() override {
cout << "Dog says: wang wang" << endl;
}
};
void makeSpeak(Animal& animal) {
animal.speak();
}
int main() {
Cat cat;
Dog dog;
makeSpeak(cat);
makeSpeak(dog);
return 0;
}
Cat says: miao miao
Dog says: wang wang
Animal 中的 speak() 被声明为 virtual
Cat 和 Dog 重写了这个函数
makeSpeak() 接收的是 Animal&
于是,当你传入不同的派生类对象时,程序会在运行时决定到底调用谁的 speak()。
五、virtual 到底有什么作用?
如果没有 virtual,即使你写了继承,调用时也不会发生真正的动态绑定。
#include <iostream>
using namespace std;
class Animal {
public:
void speak() {
cout << "Animal speaks" << endl;
}
};
class Cat : public Animal {
public:
void speak() {
cout << "Cat says: miao miao" << endl;
}
};
int main() {
Cat cat;
Animal* p = &cat;
p->speak();
return 0;
}
很多初学者看到这里会懵:
明明 p 指向的是 Cat,为什么调用的却是 Animal::speak()?
speak() 不是虚函数
- 编译器根据指针类型
Animal* 直接决定了调用基类版本
也就是说,这里发生的是 静态绑定,而不是动态绑定。
六、什么是'重写(Override)'?
在动态多态里,派生类重新定义基类的虚函数,这个过程叫 重写。
- 函数名相同
- 参数列表相同
- 返回类型兼容
- 基类函数必须是
virtual
class Base {
public:
virtual void func() {}
};
class Derived : public Base {
public:
void func() override {}
};
这里的 override 不是必须的,但强烈建议一定要写。
void func(int x) override;
如果没有 override,编译器可能认为你只是定义了一个新函数。
如果加了 override,编译器会直接报错,提醒你这不是正确的重写。
七、final 又是什么?
1. 修饰类:表示这个类不能再被继承
class Base final {};
class Derived : public Base {
2. 修饰虚函数:表示这个函数不能再被子类重写
class Base {
public:
virtual void show() final {
cout << "Base show" << endl;
}
};
class Derived : public Base {
public:
void show() override {
cout << "Derived show" << endl;
}
};
- 明确设计意图
- 防止错误扩展
- 某些情况下有利于编译器优化
八、纯虚函数是什么?
纯虚函数是指:只有声明,没有默认实现,并要求派生类必须重写的虚函数。
class Animal {
public:
virtual void speak() = 0;
};
#include <iostream>
using namespace std;
class Animal {
public:
virtual void speak() = 0;
};
class Cat : public Animal {
public:
void speak() override {
cout << "Cat says: miao miao" << endl;
}
};
int main() {
Cat cat;
cat.speak();
return 0;
}
抽象类在实际开发中非常常见,因为它特别适合定义统一规范。
- 图形系统中的
Shape
- 日志系统中的
Logger
- 支付系统中的
Payment
- 游戏角色中的
Character
这些基类往往只关心'你应该具备哪些能力',而不关心'你具体怎么实现'。
九、为什么基类析构函数通常要写成虚函数?
这是 C++ 多态里最容易踩坑、也最重要的知识点之一。
#include <iostream>
using namespace std;
class Base {
public:
~Base() {
cout << "Base destructor" << endl;
}
};
class Derived : public Base {
public:
~Derived() {
cout << "Derived destructor" << endl;
}
};
int main() {
Base* p = new Derived();
delete p;
return 0;
}
Derived destructor
Base destructor
但实际上,如果基类析构函数不是虚函数,行为是未定义的。很多实现里你只会看到基类析构执行,派生类资源根本没被正确释放。
class Base {
public:
virtual ~Base() {
cout << "Base destructor" << endl;
}
};
只要一个类可能被当作基类使用,并且你会通过基类指针删除派生类对象,就必须把析构函数写成虚函数。
十、动态多态成立的条件,缺一个都不行
很多文章讲多态,只给定义,不给判断标准。
你真正写代码时,可以用下面这 3 条快速判断:
条件 1:必须有继承
没有基类和派生类,就谈不上'同一个接口,不同实现'。
条件 2:基类函数必须是虚函数
如果不是 virtual,调用会在编译期绑定,根本不会进入运行时多态。
条件 3:必须通过基类指针或基类引用调用
Cat cat;
Animal* p1 = &cat;
Animal& r1 = cat;
p1->speak();
r1.speak();
虽然调用的是派生类版本,但这不是我们通常说的'通过基类接口触发的运行时多态'。
十一、对象切片:为什么有时候多态会失效?
#include <iostream>
using namespace std;
class Animal {
public:
virtual void speak() {
cout << "Animal speaks" << endl;
}
};
class Cat : public Animal {
public:
void speak() override {
cout << "Cat says: miao miao" << endl;
}
};
void test(Animal animal) {
animal.speak();
}
int main() {
Cat cat;
test(cat);
return 0;
}
因为这里发生了 对象切片(Object Slicing)。
Cat 传给 Animal animal 时,只保留了基类部分,派生类那部分被'切掉'了。
进入 test() 之后,这已经是一个独立的 Animal 对象,不再是原来的 Cat。
void test(Animal& animal) {
animal.speak();
}
void test(Animal* animal) {
animal->speak();
}
这类问题非常常见,尤其是初学者一边学继承一边学函数参数传递时,特别容易踩坑。
十二、C++ 多态的底层原理:虚函数表(vtable)
很多人学到 virtual 就停了,但如果你想真正理解 C++ 多态,必须知道它背后的实现思路。
1. 编译器通常会为有虚函数的类生成一张虚函数表
而每个对象内部通常会有一个隐藏指针,指向这张表,通常叫:
2. 调用虚函数时发生了什么?
Animal* p = new Cat();
p->speak();
- 通过
p 找到对象内部的 vptr
- 通过
vptr 找到当前对象所属类型的虚函数表
- 从虚函数表中取出
speak() 对应的函数地址
- 调用真正属于
Cat 的那个 speak()
3. 为什么虚函数会有一点性能开销?
因为相比普通函数调用,虚函数调用通常会多一层间接寻址。
- 这点开销通常很小
- 大多数业务代码根本不需要过度担心
- 真正该关心的是接口设计是否合理
不要一看到虚函数就谈'性能灾难',绝大多数场景远没那么夸张。
十三、静态多态和动态多态到底有什么区别?
| 对比项 | 静态多态 | 动态多态 |
|---|
| 绑定时机 | 编译期 | 运行期 |
| 常见方式 | 重载、模板 | 继承 + 虚函数 |
| 性能 | 通常更高 | 略有运行时开销 |
| 灵活性 | 相对固定 | 更灵活 |
| 典型场景 | 泛型编程、模板库 | 面向对象接口设计 |
- 静态多态 更偏泛型与编译器能力
- 动态多态 更偏面向对象与接口抽象
在现代 C++ 里,这两种多态都很重要,不是互相替代,而是各有应用场景。
十四、再看一个更像实际项目的例子
#include <iostream>
#include <vector>
#include <memory>
using namespace std;
class Shape {
public:
virtual ~Shape() = default;
virtual void draw() const = 0;
};
class Circle : public Shape {
public:
void draw() const override {
cout << "Draw Circle" << endl;
}
};
class Rectangle : public Shape {
public:
void draw() const override {
cout << "Draw Rectangle" << endl;
}
};
int main() {
vector<unique_ptr<Shape>> shapes;
shapes.push_back(make_unique<Circle>());
shapes.push_back(make_unique<Rectangle>());
for (const auto& shape : shapes) {
shape->draw();
}
return 0;
}
- 主逻辑只依赖
Shape
- 新增图形时,不需要改主流程
- 扩展性非常强
也就是说,多态不是只用来应付面试题,它本质上是为了实现:
十五、面试高频问题,一次帮你答清楚
1. 什么是多态?
2. C++ 多态分哪几种?
- 静态多态:函数重载、运算符重载、模板
- 动态多态:继承 + 虚函数 + 基类指针/引用
3. 虚函数的作用是什么?
4. 纯虚函数和虚函数有什么区别?
- 虚函数:可以有默认实现
- 纯虚函数:没有默认实现,派生类必须重写
5. 抽象类能不能实例化?
6. 为什么析构函数要写成虚函数?
为了通过基类指针删除派生类对象时,能正确调用派生类析构函数,避免资源泄漏或未定义行为。
7. 多态的底层原理是什么?
通常依赖虚函数表(vtable)和虚表指针(vptr)实现运行时分派。
8. 什么是对象切片?
派生类对象按值传递给基类对象时,派生类部分被截掉,导致多态失效。
十六、学习多态时最容易出现的 5 个误区
误区 1:有继承就一定有多态
错。
只有'继承 + 虚函数 + 基类指针/引用调用'同时满足,才是典型动态多态。
误区 2:派生类同名函数一定是重写
错。
如果参数列表不同,那可能只是重载或隐藏,不一定是重写。
所以一定要用 override。
误区 3:基类析构函数无所谓
错。
只要类可能被多态使用,析构函数就应该优先考虑写成虚函数。
误区 4:虚函数一定很慢,最好别用
错。
虚函数有成本,但通常非常小。比起那点调用开销,错误的架构设计代价更大。
误区 5:多态只适合面试,不适合项目
错。
图形系统、插件系统、日志系统、游戏对象系统、网络协议处理框架,都大量使用多态。
十七、一句话总结静态绑定和动态绑定
- 静态绑定:编译器提前决定调用谁
- 动态绑定:运行时再决定调用谁
而 virtual,就是让 C++ 拥有动态绑定能力的关键开关。
十八、如何写出更规范的多态代码?
1. 重写虚函数时始终加 override
2. 作为基类时,优先考虑虚析构
3. 纯接口类可以只保留虚函数
class IShape {
public:
virtual ~IShape() = default;
virtual void draw() const = 0;
};
4. 尽量通过引用或指针传递基类对象
5. 不要为了'用多态而多态'
如果根本没有扩展需求、没有统一接口需求,直接普通函数或模板可能更简单。
优秀的 C++ 代码,不是'到处都是多态',而是'在该抽象的地方抽象'。
十九、本文总结
同一个接口,面对不同对象,程序可以表现出不同的行为。
- C++ 多态分为静态多态和动态多态
- 动态多态的核心是
virtual
- 重写虚函数时要用
override
- 纯虚函数会形成抽象类
- 基类析构函数常常必须是虚函数
- 对象切片会让多态失效
- 底层通常依赖
vtable 和 vptr
- 先理解'同一个接口,不同表现'
- 再理解'继承 + 虚函数 + 基类引用/指针'
- 然后掌握
override、纯虚函数、虚析构
- 最后再去吃透虚函数表原理
相关免费在线工具
- 加密/解密文本
使用加密算法(如AES、TripleDES、Rabbit或RC4)加密和解密文本明文。 在线工具,加密/解密文本在线工具,online
- Gemini 图片去水印
基于开源反向 Alpha 混合算法去除 Gemini/Nano Banana 图片水印,支持批量处理与下载。 在线工具,Gemini 图片去水印在线工具,online
- Base64 字符串编码/解码
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
- Base64 文件转换器
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
- Markdown转HTML
将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online
- HTML转Markdown
将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online