3、动态多态入门:虚函数的概念、virtual关键字、虚函数与普通成员函数的区别、运行时多态的基本实现
好,我们正式开始聊动态多态。
前面两章我们把继承和静态多态(重载)讲透了。现在要进入C++里最核心、也最容易让人栽跟头的部分——虚函数。
说实话,我当年刚学C++时,对虚函数的理解就是“加个virtual关键字”。直到我在项目中写了一个基类指针数组,想统一调用派生类的行为,结果发现调用的全是基类的版本……那一刻我才真正意识到:虚函数不是语法糖,它是C++运行时多态的基石。
3.1 为什么需要虚函数?
先想一个问题:
你有一个基类 Animal,里面有个 speak() 方法。派生类 Dog 和 Cat 都重写了它。现在你用基类指针指向派生类对象:
Animal* p = new Dog();
p->speak(); // 你希望输出 "Woof!" 还是 "Animal speaks"?
如果不加 virtual,C++ 默认调用的是 指针类型(Animal) 的版本。这往往不是我们想要的。
你想想看,我们写代码时经常要“面向接口编程”,用基类指针统一管理各种派生类对象。如果调用的是基类版本,那多态就无从谈起。
核心结论:虚函数让程序在运行时根据对象的实际类型来决定调用哪个函数。这就是“运行时多态”的本质。
3.2 virtual关键字:怎么用?
用法很简单:在基类的成员函数声明前加上 virtual 关键字。
class Animal {
public:
virtual void speak() {
cout << "Animal speaks" << endl;
}
};
class Dog : public Animal {
public:
void speak() override { // C++11 推荐加 override
cout << "Woof!" << endl;
}
};
嗯,这里要注意:virtual 只在基类中写一次就够了。派生类重写时,即使不写 virtual,它依然是虚函数。但我个人习惯——派生类也写上 override。这不是为了修饰,而是为了让编译器帮你检查:你是不是真的重写了基类的虚函数。我曾经因为拼写错误(比如写成 speek)导致重写失败,排查了半天……
小技巧:从 C++11 开始,建议在派生类重写的函数后面加上 override 关键字。编译器会帮你检查签名是否匹配,避免“想重写却变成了隐藏”的坑。
3.3 虚函数 vs 普通成员函数:区别在哪?
我把区别整理成了一张表,方便你对照:
| 对比维度 | 普通成员函数 | 虚函数 |
|---|---|---|
| 绑定时机 | 编译期(静态绑定) | 运行期(动态绑定) |
| 调用依据 | 指针/引用的静态类型 | 对象的实际类型 |
| 性能开销 | 无额外开销 | 有虚函数表查找开销(很小) |
| 能否内联 | 可以 | 通常不能(除非编译器能确定实际类型) |
| 构造函数中调用 | 正常调用 | 不会触发多态(调用的是当前类的版本) |
说白了,普通函数是“编译时决定”,虚函数是“运行时决定”。
这个区别看似简单,但影响深远。我举个例子:
Animal a;
Dog d;
Animal& ref = d;
ref.speak(); // 如果 speak 是普通函数:输出 "Animal speaks"
// 如果 speak 是虚函数:输出 "Woof!"
为什么会这样?因为普通函数看的是 ref 的类型(Animal&),而虚函数看的是 ref 引用的对象类型(Dog)。
避坑指南:我曾经在构造函数里调用虚函数,以为能调用到派生类的版本。结果调用的全是基类的。记住:构造函数执行时,对象的动态类型还是基类,虚函数机制不会按你期望的方式工作。
3.4 运行时多态的基本实现
好了,理论说完了,我们来看一个完整的例子。这是运行时多态最经典的用法:
#include <iostream>
#include <vector>
using namespace std;
class Shape {
public:
virtual void draw() const {
cout << "Drawing a shape" << endl;
}
virtual ~Shape() {} // 重要!基类析构函数要虚
};
class Circle : public Shape {
public:
void draw() const override {
cout << "Drawing a circle" << endl;
}
};
class Square : public Shape {
public:
void draw() const override {
cout << "Drawing a square" << endl;
}
};
int main() {
vector<Shape*> shapes;
shapes.push_back(new Circle());
shapes.push_back(new Square());
shapes.push_back(new Circle());
for (Shape* s : shapes) {
s->draw(); // 运行时多态:每个对象调用自己的版本
}
// 别忘了释放内存
for (Shape* s : shapes) {
delete s;
}
return 0;
}
输出结果:
Drawing a circle
Drawing a square
Drawing a circle
你看,同一个 draw() 调用,因为对象实际类型不同,行为也不同。这就是运行时多态的魅力。
我个人觉得,理解这个例子的关键在于:代码写的是“对接口编程”,而不是“对实现编程”。你不需要知道每个 Shape* 具体指向什么类型,你只需要调用 draw(),它自己会做正确的事。
3.5 虚函数背后的机制:虚函数表(vtable)
你可能好奇:运行时多态是怎么实现的?
答案是——虚函数表(vtable)。
每个包含虚函数的类,编译器会为它生成一个虚函数表。这个表是一个函数指针数组,存储了该类所有虚函数的地址。每个对象内部会有一个隐藏的指针(vptr),指向所属类的虚函数表。
调用虚函数时,程序会:
- 通过对象的 vptr 找到虚函数表
- 在表中找到对应函数的地址
- 跳转到该地址执行
这个过程虽然多了一步间接寻址,但现代CPU的分支预测和缓存机制让这个开销几乎可以忽略不计。除非你在极端性能敏感的循环中调用虚函数,否则不用担心。
下面我用一张图来展示这个机制:
这张图展示了两个对象(Circle 和 Square)各自拥有独立的 vptr,指向不同的虚函数表。当调用 draw() 时,程序通过 vptr 找到对应的表,然后调用表中记录的地址——这就实现了“同一个调用,不同行为”。
3.6 关于虚函数的一些重要提醒
- 基类析构函数一定要是虚函数。否则,当你用基类指针
delete派生类对象时,只会调用基类的析构函数,派生类的资源不会被释放。这是内存泄漏的常见来源。 - 静态函数不能是虚函数。因为静态函数不属于某个对象,没有
this指针,无法通过 vptr 查找。 - 构造函数不能是虚函数。虚函数调用依赖 vptr,而 vptr 是在构造函数执行期间初始化的。在构造函数执行时,vptr 指向的是当前正在构造的类的虚函数表,不是最终派生类的。
- 内联和虚函数:理论上虚函数不能内联,因为内联发生在编译期,而虚函数调用在运行期才决定。但编译器在某些情况下(比如对象类型明确时)可能会优化掉虚函数调用,此时可以内联。
总结一句话:虚函数是C++运行时多态的灵魂。它通过虚函数表机制,让程序在运行时根据对象的实际类型选择正确的函数。理解虚函数,你就掌握了面向对象编程中最强大的武器之一。
公众号:蓝海资料掘金营,微信 deep3321