【C++】深入理解多态:从用法到原理

目录

[1. 多态的概念](#1. 多态的概念)

[2. 多态的定义及实现](#2. 多态的定义及实现)

[2.1 多态的构成条件](#2.1 多态的构成条件)

[2.2 虚函数](#2.2 虚函数)

[2.3 虚函数的重写](#2.3 虚函数的重写)

[2.4 虚函数重写的两个例外](#2.4 虚函数重写的两个例外)

[(1) 协变 (Covariance)](#(1) 协变 (Covariance))

[(2) 析构函数的重写](#(2) 析构函数的重写)

[2.5 C++11 override 和 final](#2.5 C++11 override 和 final)

[2.6 重载、覆盖(重写)、隐藏(重定义)的对比](#2.6 重载、覆盖(重写)、隐藏(重定义)的对比)

[3. 抽象类](#3. 抽象类)

[3.1 概念](#3.1 概念)

[3.2 接口继承和实现继承](#3.2 接口继承和实现继承)

[4. 多态的原理](#4. 多态的原理)

[4.1 虚函数表](#4.1 虚函数表)

[4.2 多态的原理](#4.2 多态的原理)

[4.3 动态绑定与静态绑定](#4.3 动态绑定与静态绑定)

[5. 单继承和多继承关系中的虚函数表](#5. 单继承和多继承关系中的虚函数表)

[5.1 单继承中的虚函数表](#5.1 单继承中的虚函数表)

[5.2 多继承中的虚函数表](#5.2 多继承中的虚函数表)

[6. 继承和多态常见的面试问题](#6. 继承和多态常见的面试问题)

[7. 总结](#7. 总结)


前言

在 C++ 面向对象编程中,多态(Polymorphism) 是继封装和继承之后的第三大核心特性。通俗来说,多态就是"多种形态",即去完成某个行为,当不同的对象去完成时会产生出不同的状态

本文将严格按照定义、用法、原理及面试高频考点的逻辑,带你彻底搞懂 C++ 多态。

1. 多态的概念

多态分为静态多态动态多态

  • 静态多态 :在编译期间确定,主要通过函数重载模板实现。

  • 动态多态 :在运行期间确定,主要通过继承虚函数实现。

本文主要讨论的是动态多态

生活中的例子: 比如买票这个行为。普通成年人买票是全价,学生买票是半价,军人买票是优先通道。同一个"买票"的动作,不同的人(对象)去执行,产生了不同的结果。

2. 多态的定义及实现

2.1 多态的构成条件

在继承体系中,实现多态必须同时满足以下两个严格条件:

  1. 必须通过基类的指针或者引用调用虚函数。

  2. 被调用的函数必须是虚函数 ,且派生类必须对该虚函数进行重写(覆盖)

2.2 虚函数

virtual 关键字修饰的成员函数称为虚函数。

cpp 复制代码
class Person {
public:
    virtual void BuyTicket() { 
        cout << "买票-全价" << endl; 
    }
};

2.3 虚函数的重写

派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的返回值类型、函数名、参数列表完全相同),称派生类的虚函数重写了基类的虚函数。

cpp 复制代码
#include <iostream>
using namespace std;

class Person {
public:
    virtual void BuyTicket() { cout << "买票-全价" << endl; }
};

class Student : public Person {
public:
    // 派生类重写基类虚函数
    virtual void BuyTicket() { cout << "买票-半价" << endl; }
};

class Soldier : public Person {
public:
    virtual void BuyTicket() { cout << "买票-优先" << endl; }
};

// 多态调用:传什么对象,就调什么对象的函数
void Pay(Person& p) {
    p.BuyTicket();
}

int main() {
    Person ps;
    Student st;
    Soldier so;

    Pay(ps); // 输出:买票-全价
    Pay(st); // 输出:买票-半价
    Pay(so); // 输出:买票-优先
    return 0;
}

2.4 虚函数重写的两个例外

虽然重写要求返回值、函数名、参数列表都相同,但有两个特殊情况:

(1) 协变 (Covariance)

基类与派生类虚函数返回值类型不同,但必须是父子关系的指针或引用。 即:基类虚函数返回基类对象的指针/引用,派生类虚函数返回派生类对象的指针/引用。

cpp 复制代码
class A {};
class B : public A {};

class Person {
public:
    virtual A* f() { return new A; }
};

class Student : public Person {
public:
    // 返回值类型不同(A* vs B*),但构成协变,依然是重写
    virtual B* f() { return new B; }
};
(2) 析构函数的重写

如果基类的析构函数为虚函数,此时派生类只要定义析构函数,无论是否加 virtual 关键字,都与基类析构函数构成重写。

  • 原因 :编译器在编译后会将析构函数的名称统一处理成 destructor,目的是为了构成多态,正确释放内存。

重要场景

cpp 复制代码
class Person {
public:
    virtual ~Person() { cout << "~Person()" << endl; }
};

class Student : public Person {
public:
    ~Student() { cout << "~Student()" << endl; }
};

int main() {
    // 如果析构函数不是虚函数,delete p 时只会调 Person 的析构,导致内存泄漏
    Person* p = new Student;
    delete p; // 多态调用:先调 ~Student(),再调 ~Person()
    return 0;
}

2.5 C++11 override 和 final

C++11 提供了两个关键字来帮助我们在编译阶段检查多态的正确性:

  1. final:修饰虚函数,表示该虚函数不能再被重写。

  2. override:检查派生类虚函数是否重写了基类的某个虚函数,如果没有重写(比如拼写错误),编译器报错。

cpp 复制代码
class Car {
public:
    virtual void Drive() {}
};

class Benz : public Car {
public:
    virtual void Drive() override { cout << "Benz" << endl; } // 检查是否重写成功
};

2.6 重载、覆盖(重写)、隐藏(重定义)的对比

这是一个非常容易混淆的概念,总结如下表:

|---------|---------------|------------------|-----------------|
| 特性 | 重载 (Overload) | 覆盖/重写 (Override) | 隐藏/重定义 (Hiding) |
| 作用域 | 在同一个作用域 | 分别在基类和派生类 | 分别在基类和派生类 |
| 函数名 | 相同 | 相同 | 相同 |
| 参数列表 | 必须不同 | 必须相同 | 只要函数名相同,非重写即隐藏 |
| 返回值 | 无要求 | 要求相同(协变除外) | 无要求 |
| virtual | 无要求 | 基类必须有 virtual | 无要求 |

3. 抽象类

3.1 概念

在虚函数的后面写上 =0 ,则这个函数为纯虚函数 。 包含纯虚函数的类叫做抽象类 (也叫接口类)。抽象类不能实例化出对象。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化

cpp 复制代码
class Car {
public:
    // 纯虚函数
    virtual void Drive() = 0;
};

class BMW : public Car {
public:
    virtual void Drive() {
        cout << "BMW-操控" << endl;
    }
};

int main() {
    // Car c; // 错误,抽象类不能实例化
    Car* p = new BMW;
    p->Drive();
    return 0;
}

3.2 接口继承和实现继承

  • 普通函数的继承 是一种实现继承,派生类继承了基类函数的实现,可以使用该函数。

  • 虚函数的继承 是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态。

  • 如果不实现多态,不要把函数定义成虚函数。

4. 多态的原理

4.1 虚函数表

我们先看一个问题:sizeof(Base) 是多少?

cpp 复制代码
class Base {
public:
    virtual void Func1() { cout << "Func1" << endl; }
private:
    int _b = 1;
};

在 32 位系统下,结果是 8 字节 。除了 int _b 占 4 字节外,还有一个 4 字节的指针,我们称之为虚函数表指针 (vptr)

  • 一个含有虚函数的类中至少有一个虚函数表指针,该指针指向一个数组,数组中存放的是虚函数的地址,这个数组叫做虚函数表 (vftable)

  • 虚函数表本质是一个函数指针数组,一般以 nullptr 结尾。

4.2 多态的原理

多态是如何实现的?

  1. 编译期间:编译器检查代码,如果满足多态的两个条件(指针/引用调用 + 虚函数),则生成特殊的指令。

  2. 运行期间

    • p->BuyTicket() 被调用时,程序并不知道 p 指向的是 Person 对象还是 Student 对象。

    • 程序会通过 p 指针找到对象内存中的 vptr

    • 通过 vptr 找到对应的 虚函数表

    • 在虚函数表中找到 BuyTicket 的实际地址并调用。

总结 :如果对象是 Student,vptr 指向 Student 的虚表(其中包含重写后的 Student::BuyTicket 地址);如果对象是 Person,vptr 指向 Person 的虚表。这就是"动态绑定"。

4.3 动态绑定与静态绑定

  • 静态绑定:在编译阶段就确定了函数的地址(如普通函数调用、函数重载)。

  • 动态绑定:在程序运行期间,根据具体拿到的对象类型确定程序的具体行为,调用具体的函数(多态)。

5. 单继承和多继承关系中的虚函数表

5.1 单继承中的虚函数表

在单继承中,派生类的虚函数表生成规则如下:

  1. 先将基类的虚表内容拷贝一份到派生类虚表中。

  2. 如果派生类重写了基类的某个虚函数,则用派生类自己的虚函数地址覆盖虚表中基类的虚函数地址。

  3. 派生类自己新增加的虚函数,按其在派生类中的声明次序,增加到派生类虚表的最后。

5.2 多继承中的虚函数表

如果一个类继承了两个带有虚函数的基类:

  • Derive 对象中会有两个 vptr

  • Base1 的 vptr 指向第一张虚表,Base2 的 vptr 指向第二张虚表。

  • 派生类重写了 func1,则第一张虚表中的 func1 被覆盖。

  • 注意 :派生类自己新增的虚函数(如 func3),通常会添加在第一个继承基类(Base1)的虚表后面。

6. 继承和多态常见的面试问题

Q1: inline 函数可以是虚函数吗?

  • :可以,但有前提。如果是普通调用,它依然可以被内联;如果是多态调用 ,编译器会忽略 inline 属性,因为多态需要在运行时去虚表中找地址,而内联是在编译时展开,两者冲突。

Q2: 静态成员函数可以是虚函数吗?

  • :不能。静态成员函数没有 this 指针,而虚函数的调用依赖 this 指针找到 vptr。

Q3: 构造函数可以是虚函数吗?

  • :不能。虚函数表指针 (vptr) 是在构造函数初始化列表阶段才初始化的。如果构造函数是虚函数,调用它需要查虚表,但此时虚表指针还没初始化,形成悖论。

Q4: 析构函数建议设为虚函数吗?

  • :强烈建议。如果用基类指针指向派生类对象,且基类析构函数不是虚函数,delete 时只会调用基类的析构,导致派生类资源未释放(内存泄漏)。

  • 虚析构函数 主要是为了解决 "通过基类指针删除派生类对象" (即场景 C)这一特定情况下的内存泄漏问题。除此之外的其他正常对象创建和销毁,C++ 的默认机制都能保证基类析构函数被自动调用。

Q5: 虚函数表存在哪里?虚表指针存在哪里?

    • 虚表指针 (vptr) :存在于对象的内存空间头部(通常)。

    • 虚函数表 (vftable) :存在于代码段(常量区) 。同类型的对象共享同一张虚表。

7. 总结

多态是 C++ 面向对象编程的灵魂。如果说封装是让代码"模块化",继承是让代码"复用",那么多态则是让代码变得"灵活"。

通过本文的学习,我们不仅掌握了 virtualoverride 等语法细节,更理解了多态背后的设计哲学:"接口与实现分离" 。它允许我们在不修改现有代码的前提下,通过增加新的子类来扩展功能(符合开闭原则)。虽然虚函数表机制在底层带来了一定的性能开销(空间上的 vptr 和时间上的查表跳转),但换来的是极高的代码可维护性和扩展性。

希望大家在理解底层原理的基础上,能够在未来的项目设计中灵活运用多态,写出真正"高内聚、低耦合"的优雅代码。

以上就是本期博客的全部内容,感谢各位的阅读以及观看。如果内容有误请大佬们多多指教,一定积极改进,加以学习。

相关推荐
REDcker1 小时前
软件开发者需要关注CPU指令集差异吗?
linux·c++·操作系统·c·cpu·指令集·加密算法
武子康1 小时前
Java-179 FastDFS 高并发优化思路:max_connections、线程、目录与同步
java·开发语言·nginx·性能优化·系统架构·fastdfs·fdfs
缺点内向1 小时前
如何在C#中为文本内容添加行号?
开发语言·c#·word·.net
h***8561 小时前
Rust在Web中的前端开发
开发语言·前端·rust
不知所云,1 小时前
5. SDL3 库项目引入
c++·sdl3
Chasing Aurora1 小时前
Python连接云端Linux服务器进行远程 (后端开发/深度学习)时候的注意事项
linux·开发语言·python·ubuntu·ai编程
key061 小时前
从数据安全体系逆推数据自由度的权力本质
java·开发语言
C++ 老炮儿的技术栈1 小时前
用密码学安全随机数生成256位密钥
c语言·开发语言·c++·windows·安全·密码学·visual studio
z***43841 小时前
java与mysql连接 使用mysql-connector-java连接msql
java·开发语言·mysql