什么是多态?

多态,从字面意思上看,是“多种形态”的意思。在 C++ 中,多态是面向对象编程(OOP)的三大核心特性之一(另外两个是封装继承)。它允许你使用一个统一的接口来处理不同类型的对象,使得程序具有更好的可扩展性和灵活性。

一个通俗的例子是:你有一个通用的“遥控器”(接口),这个遥控器上有个“开/关”按钮。当你用这个遥控器对着电视机按“开/关”,电视机就会打开或关闭;当你用同一个遥控器对着空调按“开/关”,空调就会打开或关闭。遥控器本身并不知道它控制的是电视机还是空调,它只知道发出一个“开/关”的指令。具体执行这个指令的是哪个设备,以及这个设备如何执行(电视机是点亮屏幕,空调是启动压缩机),是在运行时才决定的。

在 C++ 中,这个“遥控器”就是基类指针或引用,而“电视机”和“空调”就是派生类对象

C++ 中的多态分类

C++ 中的多态主要分为两类:

  1. 静态多态(编译时多态):在程序编译期间就已经确定了函数调用的地址。它的执行速度快,但灵活性稍差。
  2. 动态多态(运行时多态):在程序运行期间才能确定调用哪个函数。这是我们通常所说的 OOP 中的多态,它提供了极高的灵活性和可扩展性。

一、 静态多态 (Static Polymorphism)

静态多态主要通过函数重载模板来实现。

1. 函数重载 (Function Overloading)

允许在同一个作用域内定义多个同名函数,但它们的参数列表(参数类型、参数个数或参数顺序)必须不同。编译器在编译时,会根据你传入的实参类型来决定调用哪个具体的函数版本。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <iostream>

void print(int i) {
std::cout << "Printing an integer: " << i << std::endl;
}

void print(double d) {
std::cout << "Printing a double: " << d << std::endl;
}

void print(const std::string& s) {
std::cout << "Printing a string: " << s << std::endl;
}

int main() {
print(10); // 编译器在编译时就知道要调用 print(int)
print(3.14); // 编译器在编译时就知道要调用 print(double)
print("hello"); // 编译器在编译时就知道要调用 print(const std::string&)
return 0;
}

2. 模板 (Templates)

模板允许我们编写与类型无关的代码,也称为泛型编程。编译器会根据模板参数的实际类型,在编译时生成相应类型的函数或类。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <iostream>

// 函数模板
template<typename T>
T add(T a, T b) {
return a + b;
}

int main() {
// 编译器根据 int 生成一个 add(int, int) 版本
std::cout << "Int sum: " << add(5, 10) << std::endl;

// 编译器根据 double 生成一个 add(double, double) 版本
std::cout << "Double sum: " << add(3.5, 7.2) << std::endl;

// std::string 也重载了 + 操作符,所以也可以用
std::cout << "String concat: " << add(std::string("hello "), std::string("world")) << std::endl;

return 0;
}

静态多态的特点:

  • 决策时机:编译时。
  • 绑定方式:静态绑定(Static Binding)或早绑定(Early Binding)。
  • 效率:高,因为没有运行时的额外开销。
  • 灵活性:相对较低,所有可能性都必须在编译时确定。

二、 动态多态 (Dynamic Polymorphism)

这是 C++ 多态的核心。它依赖于继承虚函数 (Virtual Functions)基类指针/引用

实现动态多态的三个条件

  1. 继承关系:必须存在一个基类和至少一个派生类。
  2. 虚函数:基类中必须有虚函数(使用 virtual 关键字声明),并且派生类需要重写(Override)这个函数。
  3. 基类指针或引用:必须通过指向派生类对象的基类指针或引用来调用虚函数。

示例代码

让我们通过一个经典的 Animal 例子来理解。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
#include <iostream>
#include <vector>

// 1. 基类 Animal
class Animal {
public:
// 2. 声明虚函数 makeSound
virtual void makeSound() const {
std::cout << "Some generic animal sound..." << std::endl;
}

// 关键!当基类指针可能被用来删除派生类对象时,析构函数必须是虚函数
virtual ~Animal() {
std::cout << "Animal destructor called." << std::endl;
}
};

// 派生类 Dog
class Dog : public Animal {
public:
// 重写(Override)基类的虚函数
// 'override' 关键字是 C++11 引入的,它不是必须的,但强烈推荐使用
// 它可以让编译器检查你是否真的重写了基类的虚函数
void makeSound() const override {
std::cout << "Woof! Woof!" << std::endl;
}

~Dog() {
std::cout << "Dog destructor called." << std::endl;
}
};

// 派生类 Cat
class Cat : public Animal {
public:
void makeSound() const override {
std::cout << "Meow! Meow!" << std::endl;
}

~Cat() {
std::cout << "Cat destructor called." << std::endl;
}
};

void performSound(const Animal& animal) {
animal.makeSound();
}

int main() {
Dog myDog;
Cat myCat;
Animal myAnimal;

// 3. 通过基类引用调用
std::cout << "--- Calling via reference ---" << std::endl;
performSound(myDog); // 运行时,系统发现 animal 引用的是一个 Dog 对象,调用 Dog::makeSound()
performSound(myCat); // 运行时,系统发现 animal 引用的是一个 Cat 对象,调用 Cat::makeSound()
performSound(myAnimal); // 运行时,系统发现 animal 引用的是一个 Animal 对象,调用 Animal::makeSound()

std::cout << "\n--- Calling via pointer ---" << std::endl;
// 3. 通过基类指针调用
Animal* animalPtr = new Dog(); // 基类指针指向派生类对象
animalPtr->makeSound(); // 运行时,系统发现 animalPtr 指向的是一个 Dog 对象,调用 Dog::makeSound()
delete animalPtr; // 因为析构函数是虚函数,会先调用 Dog 的析构,再调用 Animal 的析构

std::cout << std::endl;

animalPtr = new Cat();
animalPtr->makeSound(); // 运行时,系统发现 animalPtr 指向的是一个 Cat 对象,调用 Cat::makeSound()
delete animalPtr;

return 0;
}

main 函数中,animalPtr 的类型是 Animal*,但它实际指向的对象的类型在运行时是可变的(DogCat)。当你调用 animalPtr->makeSound() 时,程序并不会调用 Animal::makeSound(),而是会根据 animalPtr 当前实际指向的对象类型,去调用该类型对应的 makeSound() 版本。这个过程就叫做动态绑定 (Dynamic Binding)晚绑定 (Late Binding)

动态多态的底层实现原理:虚函数表 (v-table)

C++ 编译器是如何实现动态绑定的呢?答案是虚函数表(Virtual Function Table, 简称 v-table)虚函数指针(Virtual Function Pointer, 简称 v-ptr)

  1. 虚函数表 (v-table)

    • 当一个类中包含至少一个虚函数时,编译器会为这个创建一个静态的数组,这个数组就是虚函数表(v-table)。
    • v-table 中存放的是该类所有虚函数的地址。
    • 如果派生类重写了基类的虚函数,那么在派生类的 v-table 中,相应的位置会被替换为派生类重写的那个函数的地址。如果派生类没有重写,那么它将继承基类 v-table 中的函数地址。
    • 每个拥有虚函数的类只有一张 v-table
  2. 虚函数指针 (v-ptr)

    • 当一个类的对象被创建时,如果这个类有虚函数(或继承了虚函数),编译器会在这个对象的内存布局的起始位置(通常是这样,但具体位置由编译器决定)偷偷地插入一个指针。
    • 这个指针就是虚函数指针(v-ptr),它指向该对象所属类的 v-table。
    • 每个对象实例里面都包含一个 v-ptr

调用过程剖析

让我们以上面的 animalPtr->makeSound() 为例,看看运行时发生了什么:

  1. 创建对象animalPtr = new Dog();

    • 在堆上分配 Dog 对象的内存。
    • 编译器在 Dog 对象的内存中放入一个 v-ptr。
    • 这个 v-ptr 指向 Dog 类的 v-table。Dog 的 v-table 中包含了 Dog::makeSound 的地址。
  2. 调用虚函数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 对象,于是:

  1. 调用 Dog 的析构函数 ~Dog()
  2. Dog 的析构函数执行完毕后,会自动调用其基类 Animal 的析构函数 ~Animal()

这样就保证了从派生类到基类的完整析构链,避免了内存泄漏。

黄金法则如果一个类可能被用作基类,并且你可能会通过基类指针删除派生类的对象,那么它的析构函数必须是虚函数。

纯虚函数与抽象类

  • 纯虚函数 (Pure Virtual Function):一个没有实现的虚函数,其声明方式是在函数末尾加上 = 0
    1
    2
    3
    4
    class Shape {
    public:
    virtual void draw() const = 0; // 纯虚函数
    };
  • 抽象类 (Abstract Class):包含至少一个纯虚函数的类。
    • 抽象类不能被实例化(不能创建对象)。
    • 它主要用作接口,强制所有派生类必须提供纯虚函数的具体实现。如果派生类没有实现所有纯虚函数,那么这个派生类也仍然是抽象类。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Shape {
public:
virtual double getArea() const = 0; // 纯虚函数,定义接口
virtual ~Shape() {}
};

class Circle : public Shape {
private:
double radius;
public:
Circle(double r) : radius(r) {}
// 必须实现 getArea()
double getArea() const override {
return 3.14159 * radius * radius;
}
};

// Shape* s = new Shape(); // 错误!不能实例化抽象类
Shape* c = new Circle(10.0); // 正确

总结

特性 静态多态 (编译时) 动态多态 (运行时)
实现方式 函数重载、模板 继承、虚函数、基类指针/引用
绑定时机 编译时(静态绑定/早绑定) 运行时(动态绑定/晚绑定)
底层机制 编译器根据参数类型决定调用 虚函数表 (v-table) 和虚函数指针 (v-ptr)
性能 速度快,无运行时开销 稍慢,有一次间接寻址的开销
灵活性 较低,编译时已确定 极高,允许在运行时处理未知类型的对象
典型应用 泛型编程(如STL)、数值计算 基于接口的编程、插件系统、UI框架等