18、多态:虚函数、纯虚函数、抽象类、虚函数表、动态绑定
多态,说白了就是「同一个接口,多种形态」。我刚开始学C++时,觉得这东西挺玄乎的。后来在项目中写了一个图形绘制引擎,才真正体会到它的威力——没有多态,代码会变得又臭又硬。
这一章,咱们把多态的几个核心概念掰开揉碎:虚函数、纯虚函数、抽象类、虚函数表、动态绑定。嗯,一个一个来。
18.1 什么是多态?
多态分两种:编译时多态(函数重载、运算符重载)和运行时多态(虚函数)。我们通常说的「面向对象的多态」,指的就是运行时多态。
它的核心机制是:通过基类指针或引用,调用派生类的重写函数。
举个例子:
class Animal {
public:
void speak() {
cout << "动物叫" << endl;
}
};
class Dog : public Animal {
public:
void speak() {
cout << "汪汪" << endl;
}
};
int main() {
Animal* p = new Dog();
p->speak(); // 输出:动物叫
delete p;
return 0;
}
你看,明明p指向的是Dog对象,调用的却是Animal的speak。为什么?因为speak不是虚函数,编译器只看指针类型,不看实际对象类型。
这就是没有多态的问题。那怎么解决?加个virtual关键字。
18.2 虚函数
虚函数,就是用virtual关键字修饰的成员函数。它告诉编译器:这个函数需要在运行时动态绑定,别在编译时就定死。
class Animal {
public:
virtual void speak() {
cout << "动物叫" << endl;
}
};
class Dog : public Animal {
public:
void speak() override { // C++11 推荐加 override
cout << "汪汪" << endl;
}
};
int main() {
Animal* p = new Dog();
p->speak(); // 输出:汪汪
delete p;
return 0;
}
加了virtual之后,p->speak() 就输出「汪汪」了。为什么?因为编译器知道speak是虚函数,它会在运行时去查虚函数表,找到Dog::speak来调用。
override 关键字。这能帮你检查函数签名是否匹配,避免写错函数名或参数类型。我曾经因为这个吃过亏——少写了一个const,结果重写没生效,查了半天。
18.3 纯虚函数与抽象类
有时候,基类的某个函数根本没有实现的意义。比如「动物叫」——你没法让一个抽象的「动物」发出具体的声音。这时候就该用纯虚函数。
class Animal {
public:
virtual void speak() = 0; // 纯虚函数
};
= 0 就是纯虚函数的标志。它告诉编译器:这个函数在基类中没有实现,派生类必须重写它。
包含纯虚函数的类,叫做抽象类。抽象类不能实例化:
Animal a; // 编译错误!抽象类不能创建对象
Animal* p = new Dog(); // 可以,指向派生类对象
抽象类的作用,说白了就是定义接口规范。它只告诉派生类「你要做什么」,但不告诉「怎么做」。
18.4 虚函数表(vtable)
虚函数表,是C++实现动态绑定的核心机制。每个包含虚函数的类,都有一个虚函数表。这个表是一个函数指针数组,里面存放着虚函数的地址。
每个对象内部,还藏着一个虚函数指针(vptr),指向所属类的虚函数表。当调用虚函数时,程序会通过vptr找到vtable,再从vtable中找到正确的函数地址来调用。
我画了一张图,帮你理解这个流程:
你看,p指向的是Dog对象,所以p的vptr指向Dog的vtable。调用speak时,从Dog vtable中取出的是Dog::speak的地址,自然就输出「汪汪」了。
18.5 动态绑定与静态绑定
静态绑定(早绑定):编译时就确定调用哪个函数。比如普通函数调用、函数重载。
动态绑定(晚绑定):运行时才确定调用哪个函数。比如通过基类指针调用虚函数。
| 绑定类型 | 决定时机 | 依据 | 性能 |
|---|---|---|---|
| 静态绑定 | 编译时 | 指针/引用类型 | 快 |
| 动态绑定 | 运行时 | 实际对象类型 | 稍慢(一次间接寻址) |
什么时候用动态绑定?当你需要「面向接口编程」的时候。比如写一个图形库,基类Shape有个虚函数draw(),派生类Circle、Rect各自实现draw()。调用时只需要一个Shape*指针,就能画出不同的图形。
18.6 虚析构函数
这一点很重要:如果基类的析构函数不是虚函数,通过基类指针删除派生类对象时,只会调用基类的析构函数,派生类的资源就泄漏了。
class Animal {
public:
virtual ~Animal() { // 虚析构函数
cout << "Animal 析构" << endl;
}
};
class Dog : public Animal {
char* name;
public:
Dog(const char* n) {
name = new char[strlen(n) + 1];
strcpy(name, n);
}
~Dog() override {
cout << "Dog 析构" << endl;
delete[] name;
}
};
int main() {
Animal* p = new Dog("旺财");
delete p; // 先调用 Dog 析构,再调用 Animal 析构
return 0;
}
如果不加virtual,delete p只会调用Animal的析构函数,Dog的name就泄漏了。嗯,这种bug很难查,因为程序可能运行很久才出问题。
18.7 多态的实际应用场景
多态不是用来炫技的,它解决的是实际问题。我举几个常见的:
- 插件系统:定义抽象基类接口,各个插件实现不同的派生类。主程序通过基类指针统一调用。
- 策略模式:不同的算法封装成不同的派生类,运行时动态切换。
- UI控件:基类Widget定义绘制、事件处理等虚函数,Button、Label等派生类各自实现。
- 工厂模式:工厂函数返回基类指针,实际创建哪个派生类由运行时条件决定。
说白了,多态让你的代码对扩展开放、对修改封闭。加一个新功能,只需要写一个新的派生类,不用改已有的代码。
18.8 小结
这一章的内容,我帮你理一下:
- 虚函数:用
virtual修饰,支持动态绑定。 - 纯虚函数:
= 0,基类不实现,派生类必须重写。 - 抽象类:包含纯虚函数的类,不能实例化,用于定义接口。
- 虚函数表:每个有虚函数的类一张表,存放虚函数地址。
- 动态绑定:运行时通过vptr查vtable,找到正确的函数调用。
- 虚析构函数:基类析构函数加virtual,防止资源泄漏。
多态是C++面向对象的精髓。刚开始可能觉得有点绕,但用多了就会发现,它让代码变得灵活又优雅。嗯,多写几个小项目,你就能体会到它的好了。
公众号:蓝海资料掘金营,微信deep3321