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

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

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

经典例子:

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

注意:

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


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

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

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

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

  3. 当你写 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
  • 这样系统扩展新实体时只要增加派生类和工厂逻辑就行,主循环不用改。

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


六、小结

  1. 多态的本质:用统一接口,处理多种类型/对象,让代码更通用、更易扩展。
  2. 静态多态
    • 发生在编译期;
    • 典型形式有函数重载、运算符重载、模板;
    • 性能好,但灵活性在"运行时决定类型"方面不足。
  3. 动态多态
    • 发生在运行期;
    • 依靠继承、虚函数和基类指针/引用;
    • 借助虚函数表实现,根据对象实际类型决定行为;
    • 注意虚析构函数、构造/析构中调用虚函数、对象切片等细节。
  4. 基于虚表的实现细节
    • 每个有虚函数的类有一张虚表;
    • 每个对象有一个 vptr 指向虚表;
    • 派生类重写虚函数时,相应虚表项会替换为派生类实现,没重写的仍指向基类版本------这也回答了你之前关于"只重写其中一个虚函数时虚表长什么样"的问题。
相关推荐
雾蓝回针1 小时前
[全网首发]解决Parallels Desktop运行“第五人格“时 使用涂鸦/快捷发言会导致视角偏移的问题
笔记·macos
猫猫的小茶馆1 小时前
【ARM】ARM的介绍
c语言·开发语言·arm开发·stm32·单片机·嵌入式硬件·物联网
煤球王子1 小时前
学而时习之:C++中的标准模板4
c++
蓑衣夜行1 小时前
Qt QWebEngine 开启硬件加速注意事项
开发语言·c++·qt·web·qwebengine
CoderYanger1 小时前
动态规划算法-简单多状态dp问题:15.买卖股票的最佳时机含冷冻期
开发语言·算法·leetcode·动态规划·1024程序员节
狐571 小时前
2025-12-04-牛客刷题笔记-25_12-4-质数统计
笔记·算法
齐生11 小时前
iOS 知识点 - 一篇文章弄清「输入事件系统」(【事件传递机制、响应链机制】以及相关知识点)
笔记·面试
韩曙亮2 小时前
【Web APIs】JavaScript 执行机制 ( 单线程特点 | 同步任务与异步任务 | 同步先行、异步排队 | 事件循环机制 )
开发语言·前端·javascript·异步任务·同步任务·web apis·js 引擎
Slaughter信仰2 小时前
图解大模型_生成式AI原理与实战学习笔记(前三章综合问答)
人工智能·笔记·学习