本篇笔记主要有关于 C++ OOP 的多态(polymorphism)特性。多态是 OOP 的核心特性之一,指的是相同的接口,不同的实现。在 C++ 中,多态允许我们通过基类的指针或引用,调用派生类的函数。
多态通常有两种形式:
- 静态多态,又叫做编译时多态,它是通过函数重载和模板来实现的。
- 动态多态,又叫做运行时多态,它是通过虚函数和继承来实现的。
简单来说,多态就是消息以多种形式呈现的能力。
动态多态
动态多态是通过虚函数和继承来实现的。在《C++ #4 函数》中我们已经介绍过虚函数的概念。这里我们再进行一次详细的复习,并且更深入地理解虚函数以及虚表的作用。
虚函数
虚函数(virtual function)是一种允许在运行时根据对象的实际类型决定调用哪个函数的成员函数。在基类中通过 virtual 关键字标记,并在派生类中通过 override 关键字重写,达到动态绑定的效果。例如:
C++#include <iostream>
class Base {
public:
virtual void func() {
std::cout << "Base::func()" << std::endl;
}
virtual ~Base() = default;
};
class Derived : public Base {
public:
void func() override {
std::cout << "Derived::func()" << std::endl;
}
};
在 Base 类中,我们定义了一个虚函数 func()。在派生类 Derived 中,我们通过 override 关键字重写该函数。
此时,如果我们在 main() 中输出结果:
C++int main() {
Base* obj = new Derived();
obj->func(); // 输出 Derived::func()
delete obj;
return 0;
}
注意,虽然 obj 是一个 Base 类指针,但是它的创建方式决定了它实际指向一个 Derived,因此在调用 func() 函数的时候,会使用 Derived 类中重写的 func()。
上例中体现的重点在于:虚函数实现了动态绑定,即在运行时根据对象的实际类型调用相应的函数,而并非在编译时决定。如果在编译时决定,则会被称为静态绑定。一个没有被 virtual 关键字标记的非虚函数就是静态绑定。
关于 override 还有一点值得强调:它不是可选的装饰。如果派生类的签名写错了(少一个 const、参数类型不一致),没有 override 时编译器会默默地认为你在定义一个新函数,程序照常编译,运行时却调用了基类的版本。加上 override 之后,这种情况直接是编译错误。所有重写都应该写 override。
虚表
虚表(VTable, virtual function table)是每个包含虚函数的类会生成的数据结构。虚表中保存类中虚函数的地址。每个对象通过一个隐藏的指针(被称为 vptr)指向它所属类的虚表。当调用虚函数时,程序通过 vptr 查找虚表中的函数地址并执行。
内存布局大致是这样:
Plain TextDerived 对象 Derived 的虚表(每个类一份,不在对象里)
+------------------+ +---------------------------+
| vptr | ----> | [0] &Derived::func |
+------------------+ | [1] &Derived::~Derived |
| Base 的成员 | +---------------------------+
+------------------+
| Derived 的成员 |
+------------------+
我们还是通过上面的例子了解虚表的实现机制。在这里:
- 首先创建了一个 Derived 类的对象,用指向 Base 的指针 obj 持有它。
- 编译器发现
obj->func()调用的是虚函数,于是不直接生成一次函数调用,而是生成"通过 vptr 查表再跳转"的代码。 - 运行时,obj 的 vptr 指向的是 Derived 类的虚表。
- 在 Derived 类的虚表中找到
Derived::func()的地址,调用派生类的实现。
由此可以推出几个实际结论:
- 虚函数有代价。 每次调用多一次间接寻址,而且编译器通常无法内联它——后者往往比前者更贵。
- 对象会变大。 一个只有一个
int的类,加上虚函数后sizeof会从 4 变成 16(8 字节 vptr + 4 字节 int + 4 字节填充)。在需要紧密打包的数组(粒子、组件)里,这个开销不能忽略。 - 虚表是每个类一份,不是每个对象一份。 对象里只多了一个指针。
- 不打算被继承的类应该标
final。 这让编译器有机会做去虚化(devirtualization),把虚调用还原成直接调用。
虚析构函数
特别注意,如果基类需要通过指针释放派生类对象,必须将基类的析构函数声明为虚函数,否则会导致资源泄露。例如:
C++#include <iostream>
class Base {
public:
virtual ~Base() { // 少了 virtual,下面的 delete 就是未定义行为
std::cout << "~Base()" << std::endl;
}
};
class Derived : public Base {
public:
Derived() { data = new int[100]; }
~Derived() override {
delete[] data;
std::cout << "~Derived()" << std::endl;
}
private:
int* data;
};
int main() {
Base* obj = new Derived();
delete obj;
return 0;
}
注意到,继承 Base 类的 Derived 类中有一个私有数组 data,在 Derived 类的构造函数中我们为 data 申请了空间,并在析构函数中释放了它。因此,在 main() 中 obj 作为 Base 类指针,它在遭到 delete 的时候必须要能够正确地调用到 Derived 类的析构函数——所以 Base 类中的析构函数必须是虚函数。
再强调一次,必须是虚函数。不是的话,标准规定这是未定义行为,实际后果通常是 ~Derived() 根本不执行,那 100 个 int 就永远泄漏了。
另外,析构函数与普通虚函数行为不同:调用虚析构函数时,会先调用派生类的析构函数,再调用基类的析构函数,而不是仅调用派生类的函数。这是为了确保销毁对象时资源会被逐层正确地释放。
因此,上面 main() 的输出结果是:
Plain Text~Derived()
~Base()
构造函数
在 C++ 中,构造函数不能是虚函数。我们在之前的笔记中也曾经介绍过:
- 构造顺序:先调用基类的构造函数,再调用派生类的构造函数。
- 这是因为基类部分必须先构造完成,派生类才能在其基础上进行拓展。
在构造函数内调用虚函数时,不会调用派生类重写的版本,而是调用当前类中定义的版本,保持编译时绑定:
C++struct Base {
Base() { init(); } // 这里调用的是 Base::init
virtual void init() { std::cout << "Base\n"; }
};
struct Derived : Base {
void init() override { std::cout << "Derived\n"; }
};
Derived d; // 输出 "Base",不是 "Derived"
这样的行为是有道理的:在基类构造函数执行时,派生类的对象尚未完全构造完成,派生类的成员还是未初始化的内存。如果此时调用了派生类的 init(),它很可能会读到垃圾数据。虚表指针要等到基类构造函数执行完、进入派生类构造函数时才被改写成派生类的虚表。
析构函数里同理,而且方向相反:进入基类析构函数时,派生类部分已经被销毁,虚表指针已经退回基类的虚表。
结论:不要在构造和析构函数里调用虚函数。 需要"构造完成后做一件依赖具体类型的事",就把它拆成一个独立的 init() 由调用方在构造之后显式调用,或者用工厂函数把两步封装起来。
静态多态
静态多态在编译期完成绑定,因此没有虚表、没有间接寻址,也不妨碍内联。它有三种常见形态。
函数重载
同名函数、不同参数列表,编译器在编译期根据实参类型选择具体调用哪一个:
C++void draw(const Circle& c);
void draw(const Square& s);
void draw(const Circle& c, float scale);
draw(circle); // 选第一个
draw(circle, 2.0f); // 选第三个
重载解析只看参数,不看返回类型。所以仅返回类型不同的两个函数无法构成重载。另外要注意隐式转换带来的意外匹配——这也是《C++ #5 对象与类》里建议给单参数构造函数加 explicit 的原因之一。
模板
模板是静态多态最主要的形式。它表达的是"任何满足某组操作的类型都可以用在这里",而不需要这些类型共享一个基类:
C++template <typename T>
T maxOf(const T& a, const T& b) {
return (a < b) ? b : a;
}
maxOf(3, 7); // T = int
maxOf(3.5, 2.1); // T = double
maxOf(std::string("a"), std::string("b"));
编译器为每个用到的 T 生成一份代码。这意味着:
- 零运行时开销,而且完全可以内联。
- 类型之间不需要继承关系,只需要支持用到的操作(这里是
<)。这种"隐式接口"就是 C++ 里 duck typing 的形式。 - 代价是代码膨胀(每个实例化一份代码)和编译期变长,以及模板错误信息出了名的难读。C++20 的 concepts 主要就是来解决最后这个问题的。
标准库的算法几乎全部建立在这之上:std::sort 不要求元素继承自任何 Comparable,只要求 < 可用。
CRTP
奇异递归模板模式(Curiously Recurring Template Pattern)——派生类把自己作为模板参数传给基类,让基类在编译期就知道派生类的具体类型:
C++template <typename Derived>
class Shape {
public:
void draw() {
static_cast<Derived*>(this)->drawImpl(); // 编译期解析,可内联
}
};
class Circle : public Shape<Circle> {
public:
void drawImpl() { std::cout << "Circle\n"; }
};
这实现了和虚函数一样的"基类调用派生类实现",但没有 vptr、没有虚表查找。代价是失去了运行时的统一性:Shape<Circle> 和 Shape<Square> 是两个无关的类型,没法放进同一个 std::vector 里。
怎么选
| 动态多态(虚函数) | 静态多态(模板 / CRTP) | |
|---|---|---|
| 绑定时机 | 运行时 | 编译期 |
| 开销 | vptr + 间接调用,难内联 | 零,可内联 |
| 对象大小 | 每个对象多一个指针 | 不变 |
| 类型集合 | 开放,可以后加派生类 | 封闭,编译期确定 |
| 能否同容器存放 | 能(存基类指针) | 不能 |
| 编译产物 | 一份代码 | 每个实例化一份 |
判断标准很直接:类型集合在运行时才确定,就用虚函数(插件、场景里的各种物体、状态机);类型集合在编译期就知道,且这段代码在热路径上,就用模板(数学库、容器、渲染器内部的固定管线)。
游戏引擎里两者通常并存:场景图上的 GameObject 用虚函数,因为要在运行时往里加任意类型;而每帧对几万个粒子做的运算走模板,因为多一次间接调用乘以几万就不是小数目了。
C++17 的 std::variant + std::visit 提供了第三条路:封闭的类型集合、无堆分配、无虚表,同时还能放进容器。见《C++ #11 C++17 新特性》。
