一、什么是“多态”
从字面上理解,多态就是“多种形态”。在程序设计里,它指的是:
使用统一的接口,却可以对不同类型的对象做出不同的具体行为。
更具体一点:
- 静态多态(编译期多态):
编译器在编译阶段就能决定到底调用哪一个函数、用哪个版本的代码。
代表形式:函数重载、运算符重载、模板(泛型)。 - 动态多态(运行期多态):
编译时先只“知道有这个虚函数”,真正要调用哪个实现要到运行时,根据对象的实际类型来决定。
代表形式:virtual虚函数 + 继承 + 基类指针/引用。
二、静态多态:编译期就决定一切
1. 静态多态的特点
“静态”的含义是:绑定发生在编译期。
- 编译器在编译阶段,就根据实参类型、模板参数等,把要调用的函数、生成的代码都选好、生成好。
- 运行时不会再为了“选函数”去查表,因此不需要虚表(vtable),也没额外的间接调用开销。
- 代价是:泛型代码会在编译期生成多个实例,代码体积可能增长;另外有些行为必须在编译期就能确定。
常见的静态多态形式有三个:函数重载、运算符重载、模板。
2. 函数重载
同名函数,根据参数列表的不同进行区分:
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. 运算符重载
运算符重载本质上也是一种函数重载,区别只是语法形式更自然。编译器在编译期决定调用哪个重载,所以它也是静态多态。
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++ 中实现静态多态最强大的工具。函数模板和类模板都属于参数化多态,在编译期根据类型参数生成具体代码。
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; - 通过基类指针或引用来操作派生类对象
经典例子:
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是虚函数,编译器会把它翻译成类似:// 伪代码 p->vptr[func1_index](p);
即:从对象中取出 vptr,根据函数在虚表中的位置,找到对应的函数指针,然后调用。
情景:base类写了两个虚函数func1和func2,在子类derived类中重写了func1没有重写func2
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. 抽象类与纯虚函数
有时我们只关心接口,不希望有人直接创建这个类的实例,就可以使用纯虚函数定义一个抽象类:
class shape {
public:
virtual void draw() = 0; // 纯虚函数
virtual ~shape() = default;
};
特点:
- 含有(或继承自基类的)至少一个纯虚函数的类,就是抽象类;
- 抽象类不能直接实例化:
shape s; // 编译错误; - 派生类必须把这些纯虚函数全部重写,否则它自己也是抽象类。
抽象类非常适合用来作为“接口基类”,例如游戏引擎中常见的 gameobject 基类,定义一组必须实现的接口如 update(), render() 等。
4. 虚析构函数与资源释放
动态多态中,一个非常重要但容易忽略的点是:基类析构函数要声明为 virtual。
典型情景:
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 默认参数与虚函数
默认参数是静态绑定的:它们在编译期根据静态类型来决定。
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)
derived d; base b = d; // 发生对象切片
此时 b 只是一个独立的 base 对象,派生类部分被“切掉了”,多态自然不存在了。
因此,多态设计中一般采用 base* 或 base& ,而不是按值传递/按值存储。
四、静态多态 vs 动态多态:对比与选择
简单对比一下两者的特点:
| 特性 | 静态多态(重载/模板) | 动态多态(虚函数) |
|---|---|---|
| 绑定时机 | 编译期 | 运行期 |
| 性能开销 | 无虚表开销,通常更快 | 通过虚表间接调用,有一点调用开销 |
| 代码体积 | 模板实例化可能生成很多代码 | 一般较稳定 |
| 灵活性 | 编译期就要知道所有类型 | 可以运行期决定具体类型 |
| 典型使用场景 | stl 算法、通用工具库、数值计算等 | 插件系统、ui 系统、游戏对象系统等 |
| 需要的语言特性 | 函数重载、运算符重载、模板 | 继承、虚函数、基类指针/引用 |
两者不是“谁更高级”的关系,而是各有适用场景:
- 如果你写的是通用算法、容器、工具库,适合用模板等静态多态手段;
- 如果你有一组“类型不同但接口统一”的对象要在运行期间统一管理,例如图形界面控件、游戏里的各种实体、不同格式的文件解码器,通常用动态多态更自然。
五、结合实际开发的几个例子
1. 使用静态多态写通用算法
比如写一个简单版本的 for_each:
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():
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 指向虚表;
- 派生类重写虚函数时,相应虚表项会替换为派生类实现,没重写的仍指向基类版本——这也回答了你之前关于“只重写其中一个虚函数时虚表长什么样”的问题。
到此这篇关于c++多态详解之从静态多态到动态多态的文章就介绍到这了,更多相关c++从静态多态到动态多态内容请搜索代码网以前的文章或继续浏览下面的相关文章希望大家以后多多支持代码网!
发表评论