多态:虚函数、纯虚函数、抽象类、动态绑定
多态,说白了就是「同一个接口,多种实现」。我刚开始学C++时,觉得这东西挺玄乎的。后来在项目中写了一个图形渲染引擎,才真正体会到它的威力。你想想看,如果没有多态,你要画圆、画方、画三角形,就得写三个不同的函数调用。有了多态,一个draw()搞定一切。
虚函数:多态的基石
虚函数是C++实现多态的核心机制。用virtual关键字修饰的成员函数,就是虚函数。它的作用是什么?让派生类可以覆盖基类的实现。
核心要点:基类指针或引用调用虚函数时,实际执行的是派生类的版本。这就是动态绑定。
class Shape {
public:
virtual void draw() {
std::cout << "绘制一个图形" << std::endl;
}
};
class Circle : public Shape {
public:
void draw() override {
std::cout << "绘制一个圆形" << std::endl;
}
};
class Square : public Shape {
public:
void draw() override {
std::cout << "绘制一个正方形" << std::endl;
}
};
你看,Shape里定义了draw(),Circle和Square各自覆盖了它。调用时用基类指针指向派生类对象,就能自动调用正确的版本。
void render(Shape* shape) {
shape->draw(); // 动态绑定,调用实际对象的draw()
}
int main() {
Circle c;
Square s;
render(&c); // 输出:绘制一个圆形
render(&s); // 输出:绘制一个正方形
return 0;
}
我的习惯:在派生类覆盖虚函数时,一定加上override关键字。这能帮编译器检查你是否真的覆盖成功了。我曾经因为拼写错误,少写了一个字母,结果调用了基类的版本,排查了半天。
纯虚函数与抽象类
有时候,基类的某个函数根本没有合理的默认实现。比如Shape::draw(),你说「绘制一个图形」——到底画什么?圆形还是方形?这时候就该用纯虚函数了。
纯虚函数的写法是在函数声明后面加上= 0:
class Shape {
public:
virtual void draw() = 0; // 纯虚函数
virtual ~Shape() = default; // 虚析构函数,别忘了
};
含有纯虚函数的类,就是抽象类。抽象类不能实例化。你写Shape s;编译器会报错。为什么?因为draw()没有实现,你没法调用它。
注意:抽象类虽然不能实例化,但可以定义指针或引用。这正是多态发挥作用的地方。
派生类必须实现所有纯虚函数,否则它自己也会变成抽象类。我见过不少新手写代码,派生类里漏掉了一个纯虚函数,结果编译不过去,一脸懵。
class Triangle : public Shape {
public:
void draw() override {
std::cout << "绘制一个三角形" << std::endl;
}
};
嗯,这里要注意:纯虚函数也可以有实现体。虽然很少用,但有时候基类可以提供一些公共逻辑,派生类再调用它。
动态绑定:运行时才决定调用谁
动态绑定,也叫迟绑定。意思是函数调用的目标地址在运行时才确定。与之相对的是静态绑定,编译时就确定了。
为什么会这样?因为虚函数表(vtable)。每个含有虚函数的类,编译器都会生成一个虚函数表。对象里藏着一个虚指针(vptr),指向这个表。调用虚函数时,程序通过vptr找到vtable,再找到正确的函数地址。
避坑指南:我曾经在构造函数里调用虚函数,以为会调用派生类的版本。结果调的是基类的。因为构造时派生类还没构造完,vptr指向的是基类的vtable。记住:构造函数和析构函数里,虚函数不会表现出多态行为。
来看一个动态绑定的完整例子:
void processShapes(Shape* shapes[], int count) {
for (int i = 0; i < count; ++i) {
shapes[i]->draw(); // 动态绑定
}
}
int main() {
Circle c;
Square s;
Triangle t;
Shape* shapes[] = {&c, &s, &t};
processShapes(shapes, 3);
// 输出:
// 绘制一个圆形
// 绘制一个正方形
// 绘制一个三角形
return 0;
}
你想想看,如果没有动态绑定,你得写多少个if-else或者switch?每加一个新形状,就得改一遍代码。有了多态,扩展起来轻松多了。
知识体系结构图
虚函数表(vtable)的工作原理
每个含有虚函数的类,都有一个虚函数表。这个表是个函数指针数组,存放着所有虚函数的地址。每个对象里有一个隐藏的虚指针(vptr),指向这个表。
| 类 | vtable 内容 |
|---|---|
| Shape | Shape::draw() 地址 |
| Circle | Circle::draw() 地址 |
| Square | Square::draw() 地址 |
调用shape->draw()时,实际流程是:
- 通过
shape指针找到对象的vptr - 通过vptr找到类的vtable
- 在vtable中找到
draw()的地址 - 调用该地址指向的函数
我的经验:虚函数表虽然方便,但会带来一点性能开销。每次调用虚函数,都要多一次间接寻址。不过在现代CPU上,这点开销基本可以忽略。除非你在写极度性能敏感的代码(比如游戏引擎的每帧循环),否则不用太担心。
避坑总结
- 构造函数里别调虚函数——调了也是基类的版本,白费功夫
- 析构函数要声明为虚函数——否则通过基类指针删除派生类对象时,可能不会调用派生类的析构函数,造成资源泄漏
- 用
override检查覆盖——编译器能帮你发现拼写错误或参数不匹配 - 抽象类不能实例化——但可以定义指针和引用
- 纯虚函数可以有实现——虽然不常用,但确实可以
多态是C++面向对象编程的灵魂。我个人觉得,理解虚函数表的工作原理,是掌握多态的关键。你不需要记住vtable的每个细节,但要知道它存在、知道它怎么工作。这样遇到奇怪的问题时,你至少知道往哪个方向排查。
一句话总结:虚函数让基类接口统一,派生类实现各异。动态绑定让程序在运行时自动选择正确的函数。抽象类强制派生类提供实现。这三者合在一起,就是C++多态的完整图景。