C++ 多态详解:从静态多态到动态多态
一、什么是"多态"
从字面上理解,多态就是"多种形态"。在程序设计里,它指的是:
使用统一的接口 ,却可以对不同类型的对象做出不同的具体行为。
更具体一点:
- 静态多态(编译期多态) :
编译器在编译阶段 就能决定到底调用哪一个函数、用哪个版本的代码。
代表形式:函数重载、运算符重载、模板(泛型)。 - 动态多态(运行期多态) :
编译时先只"知道有这个虚函数",真正要调用哪个实现要到运行时 ,根据对象的实际类型来决定。
代表形式:virtual虚函数 + 继承 + 基类指针/引用。
二、静态多态:编译期就决定一切
1. 静态多态的特点
"静态"的含义是:绑定发生在编译期。
- 编译器在编译阶段,就根据实参类型 、模板参数等,把要调用的函数、生成的代码都选好、生成好。
- 运行时不会再为了"选函数"去查表,因此不需要虚表(vtable),也没额外的间接调用开销。
- 代价是:泛型代码会在编译期生成多个实例,代码体积可能增长;另外有些行为必须在编译期就能确定。
常见的静态多态形式有三个:函数重载、运算符重载、模板。
2. 函数重载
同名函数,根据参数列表的不同进行区分:
cpp
void print(int x) {
std::cout << "int: " << x << std::endl;
}
void print(double x) {
std::cout << "double: " << x << std::endl;
}
void print(const std::string& s) {
std::cout << "string: " << s << std::endl;
}
int main() {
print(10); // 调用 print(int)
print(3.14); // 调用 print(double)
print("hello"); // 字面量转成 std::string,调用 print(const std::string&)
}
在这里,"多态"的表现是:同一个名字 print,可以处理不同的类型 。
编译器会在编译期进行"重载决议",选出最合适的一个版本。这就是静态多态。
补充一个和继承相关的点:
如果派生类中重新定义了与基类同名但参数不同的函数,会发生"名字隐藏"。要想保留基类的其他重载,可以用
using Base::func;把基类同名重载导入作用域。
3. 运算符重载
运算符重载本质上也是一种函数重载,区别只是语法形式更自然。编译器在编译期决定调用哪个重载,所以它也是静态多态。
cpp
struct Point {
int x, y;
Point(int x, int y) : x(x), y(y) {}
Point operator+(const Point& other) const {
return Point(x + other.x, y + other.y);
}
};
int main() {
Point a(1, 2), b(3, 4);
Point c = a + b; // 实际是调用 a.operator+(b)
std::cout << c.x << ", " << c.y << std::endl; // 4, 6
}
"同一个运算符 +" 对于不同类型(例如 int + int、Point + Point)会产生不同的行为,同样属于静态多态。
4. 模板与泛型编程
模板是 C++ 中实现静态多态最强大的工具。函数模板和类模板都属于参数化多态,在编译期根据类型参数生成具体代码。
cpp
template <typename T>
T add(T a, T b) {
return a + b; // 只要求 T 支持 operator+
}
int main() {
std::cout << add(1, 2) << std::endl; // 实例化出 add<int>
std::cout << add(1.5, 2.5) << std::endl; // 实例化出 add<double>
std::cout << add(std::string("a"), "b") << std::endl; // 实例化出 add<std::string>
}
这里的 add 在源代码里只写了一份,但编译器会根据实际调用自动生成多个版本。
本质上,它也是一种"接口相同(add),但根据类型不同产生不同行为"的多态,只是全部发生在编译期。
模板和函数重载还可以配合使用(例如
std::sort接受不同类型的迭代器、不同的比较器),本质上依然是静态多态的一种组合形式。
三、动态多态:运行期由对象说了算
静态多态的"主角"是"类型"和"模板参数",它解决的是"类型不一样 怎么共享代码"。
动态多态的"主角"是"对象的实际类型 ",解决的是"一群有共同接口的对象,具体用哪个实现要到运行期才知道"。
1. 动态多态的三个要素
C++ 中要用到动态多态,基本需要三个条件:
- 继承:有一个基类和若干派生类;
- 虚函数 :基类中把要多态调用的函数声明为
virtual; - 通过基类指针或引用来操作派生类对象
经典例子:
cpp
class Shape {
public:
virtual void draw() { // 虚函数
std::cout << "Shape::draw" << std::endl;
}
virtual ~Shape() = default; // 虚析构,后面会讲
};
class Circle : public Shape {
public:
void draw() override { // override 明确表明"重写基类虚函数"
std::cout << "Circle::draw" << std::endl;
}
};
class Rect : public Shape {
public:
void draw() override {
std::cout << "Rect::draw" << std::endl;
}
};
void render(Shape& s) {
s.draw(); // 这里发生动态绑定
}
int main() {
Circle c;
Rect r;
render(c); // 调用 Circle::draw
render(r); // 调用 Rect::draw
}
这里 render 只认识 Shape& 这个"统一接口",但传入不同的实际对象(Circle 或 Rect)时,会在运行期调用不同版本的 draw。这就是运行期多态。
注意:
如果是值传递,比如
void render(Shape s),那么会发生对象切片(object slicing) ,派生类部分被"切掉",只剩下基类部分,动态多态就失效了。因此,多态场景下要习惯性使用指针或引用。
2. 虚函数表(vtable)与 vptr 的实现原理
典型实现(大多数主流编译器采用类似思路)是这样的:
-
每个含有虚函数的类 ,编译器都会为它生成一张虚函数表(vtable),里面是一串"函数指针";
-
每个对象里 会隐藏一个指针(通常叫
vptr),指向它所属类的那张虚函数表; -
当你写
p->func1()时,如果func1是虚函数,编译器会把它翻译成类似:cpp// 伪代码 p->vptr[func1_index](p);即:从对象中取出 vptr,根据函数在虚表中的位置,找到对应的函数指针,然后调用。
情景:Base类写了两个虚函数func1和func2,在子类Derived类中重写了func1没有重写func2
cpp
class Base {
public:
virtual void func1();
virtual void func2();
};
class Derived : public Base {
public:
void func1() override;
// 没有重写 func2()
};
那么典型的虚表布局可以想象为:
-
Base的虚表大致为:index 函数 0 Base::func1 1 Base::func2 -
Derived的虚表大致为:index 函数 0 Derived::func1 1 Base::func2
也就是说:
派生类重写了哪个虚函数,对应虚表条目就改成指向派生类实现;没重写的虚函数,虚表里仍然指向基类实现。
构造与析构期间的 vptr
- 构造基类对象时,先设置 vptr 指向 基类 的虚表;
- 构造派生类对象时,在基类构造结束后,再把 vptr 改成指向 派生类 虚表;
- 析构时顺序相反。
这带来的一个重要结论是:
在构造函数或析构函数内部调用虚函数时,不会表现出"派生类版本",而是调用当前构造/析构阶段对应类的版本。这是为了避免访问尚未构造/已经销毁的派生类成员。
3. 抽象类与纯虚函数
有时我们只关心接口,不希望有人直接创建这个类的实例,就可以使用纯虚函数 定义一个抽象类:
cpp
class Shape {
public:
virtual void draw() = 0; // 纯虚函数
virtual ~Shape() = default;
};
特点:
- 含有(或继承自基类的)至少一个纯虚函数的类,就是抽象类;
- 抽象类不能直接实例化:
Shape s; // 编译错误; - 派生类必须把这些纯虚函数全部重写,否则它自己也是抽象类。
抽象类非常适合用来作为"接口基类",例如游戏引擎中常见的 GameObject 基类,定义一组必须实现的接口如 update(), render() 等。
4. 虚析构函数与资源释放
动态多态中,一个非常重要但容易忽略的点是:基类析构函数要声明为 virtual。
典型情景:
cpp
class Base {
public:
virtual ~Base() { // 必须是虚析构
std::cout << "Base dtor\n";
}
};
class Derived : public Base {
public:
~Derived() {
std::cout << "Derived dtor\n";
}
};
int main() {
Base* p = new Derived();
delete p;
}
如果 ~Base() 不是虚函数,那么 delete p; 只会调用 Base 的析构函数,而不会调用 Derived 的析构函数,导致派生类中资源泄漏。这在实际工程里非常危险。
只要你打算通过 Base* 或 Base& 以多态方式管理对象生命周期,就应该把基类析构函数声明为 virtual。
5. 动态多态的一些细节注意
5.1 默认参数与虚函数
默认参数是静态绑定 的:它们在编译期根据静态类型来决定。
cpp
class Base {
public:
virtual void func(int x = 1) {
std::cout << "Base: " << x << std::endl;
}
};
class Derived : public Base {
public:
void func(int x = 2) override {
std::cout << "Derived: " << x << std::endl;
}
};
int main() {
Derived d;
Base* p = &d;
p->func(); // 输出什么?
}
这里:
- 调用的函数体是
Derived::func(虚函数,运行期绑定); - 但默认参数值是以
p的静态类型Base*为准,所以默认值是1。
最终输出:Derived: 1。
所以建议:不要依赖虚函数的默认参数来区分行为,或者干脆在基类中避免给虚函数提供默认参数。
5.2 对象切片(object slicing)
cpp
Derived d;
Base b = d; // 发生对象切片
此时 b 只是一个独立的 Base 对象,派生类部分被"切掉了",多态自然不存在了。
因此,多态设计中一般采用 Base* 或 Base& ,而不是按值传递/按值存储。
四、静态多态 vs 动态多态:对比与选择
简单对比一下两者的特点:
| 特性 | 静态多态(重载/模板) | 动态多态(虚函数) |
|---|---|---|
| 绑定时机 | 编译期 | 运行期 |
| 性能开销 | 无虚表开销,通常更快 | 通过虚表间接调用,有一点调用开销 |
| 代码体积 | 模板实例化可能生成很多代码 | 一般较稳定 |
| 灵活性 | 编译期就要知道所有类型 | 可以运行期决定具体类型 |
| 典型使用场景 | STL 算法、通用工具库、数值计算等 | 插件系统、UI 系统、游戏对象系统等 |
| 需要的语言特性 | 函数重载、运算符重载、模板 | 继承、虚函数、基类指针/引用 |
两者不是"谁更高级"的关系,而是各有适用场景:
- 如果你写的是通用算法、容器、工具库,适合用模板等静态多态手段;
- 如果你有一组"类型不同但接口统一"的对象要在运行期间统一管理,例如图形界面控件、游戏里的各种实体、不同格式的文件解码器,通常用动态多态更自然。
五、结合实际开发的几个例子
1. 使用静态多态写通用算法
比如写一个简单版本的 for_each:
cpp
template <typename It, typename Func>
void my_for_each(It first, It last, Func f) {
for (; first != last; ++first) {
f(*first);
}
}
int main() {
std::vector<int> v{1, 2, 3};
my_for_each(v.begin(), v.end(), [](int x) {
std::cout << x << " ";
});
}
Func可以是函数指针、函数对象、lambda;It可以是各种迭代器;- 编译器会根据实际类型生成具体代码,运行时基本没有额外开销。
这就是典型的静态多态用法,也是 STL 的设计思想。
2. 使用动态多态做"对象系统"(例如游戏里的实体)
假设一个游戏里有不同的实体:玩家、怪物、NPC,都需要 update():
cpp
class Entity {
public:
virtual void update(float dt) = 0; // 纯虚函数
virtual ~Entity() = default;
};
class Player : public Entity {
public:
void update(float dt) override {
// 处理玩家输入、移动等
}
};
class Monster : public Entity {
public:
void update(float dt) override {
// AI 行为
}
};
void updateAll(std::vector<std::unique_ptr<Entity>>& entities, float dt) {
for (auto& e : entities) {
e->update(dt); // 动态多态,运行期调用对应实体的 update
}
}
在这里:
- 游戏主循环只需要维持一个
std::vector<std::unique_ptr<Entity>>; - 不关心具体是
Player还是Monster,全部通过多态调用update; - 这样系统扩展新实体时只要增加派生类和工厂逻辑就行,主循环不用改。
典型地,这种需要"运行时混合多种类型"的场景,非常适合用动态多态。
六、小结
- 多态的本质:用统一接口,处理多种类型/对象,让代码更通用、更易扩展。
- 静态多态 :
- 发生在编译期;
- 典型形式有函数重载、运算符重载、模板;
- 性能好,但灵活性在"运行时决定类型"方面不足。
- 动态多态 :
- 发生在运行期;
- 依靠继承、虚函数和基类指针/引用;
- 借助虚函数表实现,根据对象实际类型决定行为;
- 注意虚析构函数、构造/析构中调用虚函数、对象切片等细节。
- 基于虚表的实现细节 :
- 每个有虚函数的类有一张虚表;
- 每个对象有一个 vptr 指向虚表;
- 派生类重写虚函数时,相应虚表项会替换为派生类实现,没重写的仍指向基类版本------这也回答了你之前关于"只重写其中一个虚函数时虚表长什么样"的问题。