C++ 多态核心原理与虚函数表详解
C++ 多态概念,区分静态与动态多态。重点讲解虚函数、override、纯虚函数、抽象类及虚析构函数的作用。深入剖析底层虚函数表(vtable)机制,分析对象切片问题及常见误区。结合图形系统实例说明工程应用,并总结面试高频考点,帮助读者掌握面向对象编程核心思想。

C++ 多态概念,区分静态与动态多态。重点讲解虚函数、override、纯虚函数、抽象类及虚析构函数的作用。深入剖析底层虚函数表(vtable)机制,分析对象切片问题及常见误区。结合图形系统实例说明工程应用,并总结面试高频考点,帮助读者掌握面向对象编程核心思想。

多态,英文是 Polymorphism,字面意思就是'多种形态'。
在 C++ 里,多态指的是:
同一个接口,作用于不同对象时,可以表现出不同的行为。
举个最简单的例子:
speak() 接口虽然调用方式一样,但不同对象的执行结果不一样,这就是多态。
因为多态可以让程序:
if...else 或 switch...case 判断比如你写一个游戏系统:
如果不用多态,你可能会写很多类型判断。
如果用了多态,你只需要统一调用 attack(),具体怎么打,交给各自的类自己决定。
这就是多态真正的价值:面向接口编程,而不是面向具体实现编程。
C++ 的多态,通常分为两大类:
也叫早绑定,编译阶段就已经确定要调用哪个函数。
常见形式:
示例:
#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) 调用哪个函数,编译器在编译时就已经知道了。
也叫晚绑定,真正调用哪个函数,要到程序运行时才能确定。
动态多态通常依赖 3 个条件:
这也是 C++ 面试里最常问的一类多态。
#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() 被声明为 virtualCat 和 Dog 重写了这个函数makeSpeak() 接收的是 Animal&于是,当你传入不同的派生类对象时,程序会在运行时决定到底调用谁的 speak()。
这就是 动态多态。
virtual 到底有什么作用?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;
}
输出结果是:
Animal speaks
很多初学者看到这里会懵:
明明 p 指向的是 Cat,为什么调用的却是 Animal::speak()?
原因很简单:
speak() 不是虚函数Animal* 直接决定了调用基类版本也就是说,这里发生的是 静态绑定,而不是动态绑定。
在动态多态里,派生类重新定义基类的虚函数,这个过程叫 重写。
规则通常是:
virtual推荐写法:
class Base {
public:
virtual void func() {}
};
class Derived : public Base {
public:
void func() override {}
};
这里的 override 不是必须的,但强烈建议一定要写。
因为它能帮你在编译期发现错误。
比如你本来想重写:
void func() override;
结果手滑写成了:
void func(int x) override;
如果没有 override,编译器可能认为你只是定义了一个新函数。
如果加了 override,编译器会直接报错,提醒你这不是正确的重写。
所以,现代 C++ 中的建议是:
只要是重写虚函数,就加 override。
final 又是什么?final 可以用于限制继承或限制重写。
class Base final {};
class Derived : public Base { // 错误 };
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;
};
这里的 = 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() {
// Animal a; // 错误,抽象类不能实例化
Cat cat;
cat.speak();
return 0;
}
抽象类在实际开发中非常常见,因为它特别适合定义统一规范。
比如:
ShapeLoggerPaymentCharacter这些基类往往只关心'你应该具备哪些能力',而不关心'你具体怎么实现'。
这是 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 条快速判断:
没有基类和派生类,就谈不上'同一个接口,不同实现'。
如果不是 virtual,调用会在编译期绑定,根本不会进入运行时多态。
示例:
Cat cat;
Animal* p1 = &cat;
Animal& r1 = cat;
p1->speak(); // 多态
r1.speak(); // 多态
如果你直接这样写:
Cat cat;
cat.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;
}
输出是:
Animal speaks
为什么?
因为这里发生了 对象切片(Object Slicing)。
Cat 传给 Animal animal 时,只保留了基类部分,派生类那部分被'切掉'了。
进入 test() 之后,这已经是一个独立的 Animal 对象,不再是原来的 Cat。
正确做法应该是传引用或指针:
void test(Animal& animal) {
animal.speak();
}
或者:
void test(Animal* animal) {
animal->speak();
}
这类问题非常常见,尤其是初学者一边学继承一边学函数参数传递时,特别容易踩坑。
很多人学到 virtual 就停了,但如果你想真正理解 C++ 多态,必须知道它背后的实现思路。
这张表里存的是虚函数地址,通常叫:
vtable:虚函数表而每个对象内部通常会有一个隐藏指针,指向这张表,通常叫:
vptr:虚表指针比如:
Animal* p = new Cat();
p->speak();
大致过程可以理解为:
p 找到对象内部的 vptrvptr 找到当前对象所属类型的虚函数表speak() 对应的函数地址Cat 的那个 speak()所以,动态多态的核心不是'看起来像魔法',而是:
运行时通过虚表指针间接找到正确函数地址。
因为相比普通函数调用,虚函数调用通常会多一层间接寻址。
不过要注意:
不要一看到虚函数就谈'性能灾难',绝大多数场景远没那么夸张。
很多人会把这两个概念混在一起,这里一次讲明白。
| 对比项 | 静态多态 | 动态多态 |
|---|---|---|
| 绑定时机 | 编译期 | 运行期 |
| 常见方式 | 重载、模板 | 继承 + 虚函数 |
| 性能 | 通常更高 | 略有运行时开销 |
| 灵活性 | 相对固定 | 更灵活 |
| 典型场景 | 泛型编程、模板库 | 面向对象接口设计 |
你可以简单理解为:
在现代 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这就是多态在工程里的经典使用方式。
也就是说,多态不是只用来应付面试题,它本质上是为了实现:
高扩展、低耦合的程序设计。
同一个接口,面对不同对象,表现出不同的行为。
支持运行时动态绑定,从而实现动态多态。
不能。只要类里有纯虚函数,它就是抽象类。
为了通过基类指针删除派生类对象时,能正确调用派生类析构函数,避免资源泄漏或未定义行为。
通常依赖虚函数表(vtable)和虚表指针(vptr)实现运行时分派。
派生类对象按值传递给基类对象时,派生类部分被截掉,导致多态失效。
错。
只有'继承 + 虚函数 + 基类指针/引用调用'同时满足,才是典型动态多态。
错。
如果参数列表不同,那可能只是重载或隐藏,不一定是重写。
所以一定要用 override。
错。
只要类可能被多态使用,析构函数就应该优先考虑写成虚函数。
错。
虚函数有成本,但通常非常小。比起那点调用开销,错误的架构设计代价更大。
错。
图形系统、插件系统、日志系统、游戏对象系统、网络协议处理框架,都大量使用多态。
你可以这样记:
而 virtual,就是让 C++ 拥有动态绑定能力的关键开关。
这里给你几条很实用的建议:
override这不是'风格问题',而是减少低级错误的有效手段。
尤其当类会被多态使用时,这几乎是默认动作。
例如:
class IShape {
public:
virtual ~IShape() = default;
virtual void draw() const = 0;
};
这是项目里很常见的接口设计方式。
避免对象切片,保证多态生效。
如果根本没有扩展需求、没有统一接口需求,直接普通函数或模板可能更简单。
优秀的 C++ 代码,不是'到处都是多态',而是'在该抽象的地方抽象'。
C++ 的多态,本质上是在说:
同一个接口,面对不同对象,程序可以表现出不同的行为。
你需要重点掌握的内容有:
virtualoverridevtable 和 vptr如果你是初学者,建议你按这个顺序去记忆:
override、纯虚函数、虚析构
微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
使用加密算法(如AES、TripleDES、Rabbit或RC4)加密和解密文本明文。 在线工具,加密/解密文本在线工具,online
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML 转 Markdown 互为补充。 在线工具,Markdown 转 HTML在线工具,online
将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML 转 Markdown在线工具,online
通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online