当前位置: 首页 > news >正文

C++ 多态详解:从静态多态到动态多态 - 实践

C++ 多态详解:从静态多态到动态多态

一、什么是“多态”

从字面上理解,多态就是“多种形态”。在程序设计里,它指的是:

使用统一的接口,却可以对不同类型的对象做出不同的具体行为。

更具体一点:


二、静态多态:编译期就决定一切

1. 静态多态的特点

“静态”的含义是:绑定发生在编译期

常见的静态多态形式有三个:函数重载、运算符重载、模板


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 + intPoint + 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++ 中要用到动态多态,基本需要三个条件:

  1. 继承:有一个基类和若干派生类;
  2. 虚函数:基类中把要多态调用的函数声明为 virtual
  3. 通过基类指针或引用来操作派生类对象

经典例子:

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& 这个“统一接口”,但传入不同的实际对象(CircleRect)时,会在运行期调用不同版本的 draw。这就是运行期多态

注意:
如果是值传递,比如 void render(Shape s),那么会发生对象切片(object slicing),派生类部分被“切掉”,只剩下基类部分,动态多态就失效了。因此,多态场景下要习惯性使用指针或引用


2. 虚函数表(vtable)与 vptr 的实现原理

典型实现(大多数主流编译器采用类似思路)是这样的:

  1. 每个含有虚函数的类,编译器都会为它生成一张虚函数表(vtable),里面是一串“函数指针”;

  2. 每个对象里会隐藏一个指针(通常叫 vptr),指向它所属类的那张虚函数表;

  3. 当你写 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函数
    0Base::func1
    1Base::func2
  • Derived 的虚表大致为:

    index函数
    0Derived::func1
    1Base::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
  • 这样系统扩展新实体时只要增加派生类和工厂逻辑就行,主循环不用改。

典型地,这种需要“运行时混合多种类型”的场景,非常适合用动态多态。


六、小结

  1. 多态的本质:用统一接口,处理多种类型/对象,让代码更通用、更易扩展。
  2. 静态多态
    • 发生在编译期;
    • 典型形式有函数重载、运算符重载、模板;
    • 性能好,但灵活性在“运行时决定类型”方面不足。
  3. 动态多态
    • 发生在运行期;
    • 依靠继承、虚函数和基类指针/引用;
    • 借助虚函数表实现,根据对象实际类型决定行为;
    • 注意虚析构函数、构造/析构中调用虚函数、对象切片等细节。
  4. 基于虚表的实现细节
    • 每个有虚函数的类有一张虚表;
    • 每个对象有一个 vptr 指向虚表;
    • 派生类重写虚函数时,相应虚表项会替换为派生类实现,没重写的仍指向基类版本——这也回答了你之前关于“只重写其中一个虚函数时虚表长什么样”的问题。
http://www.jsqmd.com/news/194398/

相关文章:

  • C++学习笔记 52 constexpr
  • 常见4K HDR信号的视频格式HLG或PQ映射
  • ssm社区宠物信息管理系统vue
  • 导师推荐8个一键生成论文工具,MBA毕业论文轻松搞定!
  • ssm院线票务系统 电影院 售票选座vue
  • 基于主从博弈理论的共享储能与微网优化运行研究:Stackelberg均衡解的存在唯一性及MAT...
  • ssm面向中小企业的人力资源培训绩效信息管理系统vue
  • DM8数据库配置深度实践与国产化生态思考
  • dubbo从1.0升级到3
  • 基于springboot框架的创意方案评选平台发布的设计与实现vue
  • 2026年粉底液瓶订制厂家top5推荐,广东广州等地优质品牌深度解析及选择指南 - 全局中转站
  • 102302125 数据采集第4次作业
  • MulVal安装记录
  • 2026 MBA必备!9个降AI率工具测评榜单
  • TensorFlow自动微分提速技巧
  • 【专业词汇】人类情绪的精细光谱:27种情绪与传统“七情”的对比
  • 学长亲荐!自考必备8款一键生成论文工具TOP8测评
  • 如何使用jmeter进行压测
  • QGroundControl
  • 全网最全10个AI论文平台,本科生轻松搞定毕业论文!
  • Python自动化测试学习-PO设计模式
  • HTTP服务器建立请求解析与响应构建:从基础架构到动态交互
  • 设计一个“完美“的测试用例,用户登录模块实例...
  • 一文告诉你黑盒测试、白盒测试、集成测试和系统测试的区别与联系
  • 一文讲透彻!RobotFramwork测试框架教程(全能)
  • 介绍java中常用于处理 Excel 档案的Apache POI
  • 明日方舟作战记录
  • 永久隐藏机械革命控制台右下角托盘图标方法
  • 2026年护肤品包材订制厂家top5推荐,广东广州等地优质品牌深度解析及选择指南 - 全局中转站
  • 3DMAX自由切割器插件FreeSlicer使用方法详解 - 实践