CPP学习笔记—多态
什么是多态?
多态,从字面意思上看,是“多种形态”的意思。在 C++ 中,多态是面向对象编程(OOP)的三大核心特性之一(另外两个是封装和继承)。它允许你使用一个统一的接口来处理不同类型的对象,使得程序具有更好的可扩展性和灵活性。
一个通俗的例子是:你有一个通用的“遥控器”(接口),这个遥控器上有个“开/关”按钮。当你用这个遥控器对着电视机按“开/关”,电视机就会打开或关闭;当你用同一个遥控器对着空调按“开/关”,空调就会打开或关闭。遥控器本身并不知道它控制的是电视机还是空调,它只知道发出一个“开/关”的指令。具体执行这个指令的是哪个设备,以及这个设备如何执行(电视机是点亮屏幕,空调是启动压缩机),是在运行时才决定的。
在 C++ 中,这个“遥控器”就是基类指针或引用,而“电视机”和“空调”就是派生类对象。
C++ 中的多态分类
C++ 中的多态主要分为两类:
- 静态多态(编译时多态):在程序编译期间就已经确定了函数调用的地址。它的执行速度快,但灵活性稍差。
- 动态多态(运行时多态):在程序运行期间才能确定调用哪个函数。这是我们通常所说的 OOP 中的多态,它提供了极高的灵活性和可扩展性。
一、 静态多态 (Static Polymorphism)
静态多态主要通过函数重载和模板来实现。
1. 函数重载 (Function Overloading)
允许在同一个作用域内定义多个同名函数,但它们的参数列表(参数类型、参数个数或参数顺序)必须不同。编译器在编译时,会根据你传入的实参类型来决定调用哪个具体的函数版本。
示例:
1 |
|
2. 模板 (Templates)
模板允许我们编写与类型无关的代码,也称为泛型编程。编译器会根据模板参数的实际类型,在编译时生成相应类型的函数或类。
示例:
1 |
|
静态多态的特点:
- 决策时机:编译时。
- 绑定方式:静态绑定(Static Binding)或早绑定(Early Binding)。
- 效率:高,因为没有运行时的额外开销。
- 灵活性:相对较低,所有可能性都必须在编译时确定。
二、 动态多态 (Dynamic Polymorphism)
这是 C++ 多态的核心。它依赖于继承、虚函数 (Virtual Functions) 和基类指针/引用。
实现动态多态的三个条件
- 继承关系:必须存在一个基类和至少一个派生类。
- 虚函数:基类中必须有虚函数(使用
virtual关键字声明),并且派生类需要重写(Override)这个函数。 - 基类指针或引用:必须通过指向派生类对象的基类指针或引用来调用虚函数。
示例代码
让我们通过一个经典的 Animal 例子来理解。
1 |
|
在 main 函数中,animalPtr 的类型是 Animal*,但它实际指向的对象的类型在运行时是可变的(Dog 或 Cat)。当你调用 animalPtr->makeSound() 时,程序并不会调用 Animal::makeSound(),而是会根据 animalPtr 当前实际指向的对象类型,去调用该类型对应的 makeSound() 版本。这个过程就叫做动态绑定 (Dynamic Binding) 或晚绑定 (Late Binding)。
动态多态的底层实现原理:虚函数表 (v-table)
C++ 编译器是如何实现动态绑定的呢?答案是虚函数表(Virtual Function Table, 简称 v-table)和虚函数指针(Virtual Function Pointer, 简称 v-ptr)。
虚函数表 (v-table):
- 当一个类中包含至少一个虚函数时,编译器会为这个类创建一个静态的数组,这个数组就是虚函数表(v-table)。
- v-table 中存放的是该类所有虚函数的地址。
- 如果派生类重写了基类的虚函数,那么在派生类的 v-table 中,相应的位置会被替换为派生类重写的那个函数的地址。如果派生类没有重写,那么它将继承基类 v-table 中的函数地址。
- 每个拥有虚函数的类只有一张 v-table。
虚函数指针 (v-ptr):
- 当一个类的对象被创建时,如果这个类有虚函数(或继承了虚函数),编译器会在这个对象的内存布局的起始位置(通常是这样,但具体位置由编译器决定)偷偷地插入一个指针。
- 这个指针就是虚函数指针(v-ptr),它指向该对象所属类的 v-table。
- 每个对象实例里面都包含一个 v-ptr。
调用过程剖析
让我们以上面的 animalPtr->makeSound() 为例,看看运行时发生了什么:
创建对象:
animalPtr = new Dog();- 在堆上分配
Dog对象的内存。 - 编译器在
Dog对象的内存中放入一个 v-ptr。 - 这个 v-ptr 指向
Dog类的 v-table。Dog的 v-table 中包含了Dog::makeSound的地址。
- 在堆上分配
调用虚函数:
animalPtr->makeSound();- 程序通过
animalPtr访问它所指向的对象(那个Dog对象)。 - 通过对象内存中的 v-ptr 找到
Dog类的 v-table。 - 在 v-table 中查找
makeSound函数的地址(虚函数在 v-table 中的偏移量是固定的,在编译时就确定了)。 - 根据找到的地址,调用
Dog::makeSound()函数。
- 程序通过
整个过程可以简化为:对象地址 -> v-ptr -> v-table -> 虚函数地址 -> 调用函数。
这个通过指针间接查找函数地址的过程,虽然带来了一点点性能开销(一次指针跳转和一次数组索引),但它实现了在运行时根据对象的实际类型来调用正确函数的能力,这就是动态多态的精髓。
为什么析构函数需要是虚函数?
这是一个非常重要的知识点。在上面的例子中,Animal 的析构函数被声明为 virtual。
原因:考虑 delete animalPtr; 这行代码。animalPtr 的静态类型是 Animal*。如果析构函数不是虚函数,编译器会进行静态绑定,只会调用 Animal 的析构函数 ~Animal()。这会导致 animalPtr 指向的 Dog 对象部分没有被正确析构(~Dog() 不会被调用),从而造成内存泄漏或资源泄漏。
当析构函数是虚函数时,delete 操作也会通过 v-table 进行动态绑定。系统会发现 animalPtr 实际指向 Dog 对象,于是:
- 调用
Dog的析构函数~Dog()。 Dog的析构函数执行完毕后,会自动调用其基类Animal的析构函数~Animal()。
这样就保证了从派生类到基类的完整析构链,避免了内存泄漏。
黄金法则:如果一个类可能被用作基类,并且你可能会通过基类指针删除派生类的对象,那么它的析构函数必须是虚函数。
纯虚函数与抽象类
- 纯虚函数 (Pure Virtual Function):一个没有实现的虚函数,其声明方式是在函数末尾加上
= 0。1
2
3
4class Shape {
public:
virtual void draw() const = 0; // 纯虚函数
}; - 抽象类 (Abstract Class):包含至少一个纯虚函数的类。
- 抽象类不能被实例化(不能创建对象)。
- 它主要用作接口,强制所有派生类必须提供纯虚函数的具体实现。如果派生类没有实现所有纯虚函数,那么这个派生类也仍然是抽象类。
示例:
1 | class Shape { |
总结
| 特性 | 静态多态 (编译时) | 动态多态 (运行时) |
|---|---|---|
| 实现方式 | 函数重载、模板 | 继承、虚函数、基类指针/引用 |
| 绑定时机 | 编译时(静态绑定/早绑定) | 运行时(动态绑定/晚绑定) |
| 底层机制 | 编译器根据参数类型决定调用 | 虚函数表 (v-table) 和虚函数指针 (v-ptr) |
| 性能 | 速度快,无运行时开销 | 稍慢,有一次间接寻址的开销 |
| 灵活性 | 较低,编译时已确定 | 极高,允许在运行时处理未知类型的对象 |
| 典型应用 | 泛型编程(如STL)、数值计算 | 基于接口的编程、插件系统、UI框架等 |









