【C++多态】

多态是 C++ 面向对象三大特性(封装、继承、多态)的灵魂,也是 C++ 面向对象编程的核心能力。如果说封装是把类的属性和行为打包、继承是实现类层次的代码复用,那么多态就是让继承体系中的类拥有 "多种形态",实现接口统一,行为差异化,也是 C++ 实现面向接口编程、代码高扩展性的基础。

本文将从零开始,围绕多态的核心概念、实现条件、语法细节,结合实战代码示例,帮你学习 C++ 多态。

一、多态的核心概念:什么是多态?

通俗来说,多态就是 "同一种行为,不同的对象执行,会产生不同的结果"。

举个最经典的现实例子:同样是 "买票" 这个行为,普通人买票是全价,学生买票是半价优惠,军人买票是优先购票;同样是 "动物叫" 这个行为,猫对象执行是 "喵",狗对象执行是 "汪汪"。同一种行为,不同对象执行,产生了完全不同的结果,这就是多态

在 C++ 中,多态分为两大类,二者的核心区别在于 "确定函数调用的时机不同":

多态类型 别名 核心实现 函数调用确定时机
编译时多态 静态多态 函数重载、函数模板 编译阶段就确定了要调用的函数
运行时多态 动态多态 继承 + 虚函数重写 + 基类指针 / 引用调用 程序运行时,根据指向的对象确定要调用的函数

二、多态的实现:两个必要条件,缺一不可

运行时多态的实现,必须同时满足以下两个核心条件,少一个都无法形成多态:
1.继承体系中,派生类必须完成对基类虚函数的重写(也叫覆盖)**; 2.必须通过基类的指针或者引用,去调用这个虚函数。

下面我们逐一拆解这两个条件的核心细节。

2.1 基础概念:虚函数

什么是虚函数?在类的非静态成员函数前面,加上virtual关键字修饰,这个函数就叫做虚函数。

注意两个核心限制:

只有类的非静态成员函数可以被定义为虚函数,全局函数、静态成员函数都不能加virtual;

构造函数不能是虚函数,析构函数可以(而且很多场景下必须是)虚函数。

cpp 复制代码
class Person {
public:
    // 虚函数:加virtual修饰的成员函数
    virtual void BuyTicket() { 
        cout << "普通人买票:全价" << endl;
    }
};

2.2 核心规则:虚函数的重写(覆盖)

虚函数的重写(也叫覆盖),是多态的核心前提。派生类中有一个和基类完全相同的虚函数,就称派生类的虚函数重写了基类的虚函数。

这里的 "完全相同",有严格的三要素要求:
函数名完全相同 参数列表(个数、类型、顺序)完全相同 返回值类型完全相同(唯一例外:协变,后面会讲)

同时,两个函数必须都是虚函数:基类函数必须加virtual,派生类函数可以不加virtual(因为继承了基类的虚函数属性,依然保持虚函数特性),但这种写法不规范,强烈建议派生类重写的虚函数也加上virtual,提升代码可读性。

2.3 完整的多态代码示例

我们用经典的买票场景,实现一个完整的多态示例:

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

// 基类:人
class Person {
public:
    // 虚函数:买票行为
    virtual void BuyTicket() { 
        cout << "普通人买票:全价" << endl;
    }
};

// 派生类:学生,公有继承Person
class Student : public Person {
public:
    // 重写基类的虚函数
    virtual void BuyTicket() override { // C++11 override关键字,辅助检查重写
        cout << "学生买票:半价优惠" << endl;
    }
};

// 派生类:军人,公有继承Person
class Soldier : public Person {
public:
    // 重写基类的虚函数
    virtual void BuyTicket() override {
        cout << "军人买票:优先购票" << endl;
    }
};

// 多态调用:基类引用作为参数
void Buy(Person& people) {
    // 核心:基类引用调用虚函数,触发多态
    // 运行时根据传入的对象类型,决定调用哪个类的BuyTicket
    people.BuyTicket();
}

int main() {
    Person p;
    Student s;
    Soldier so;

    // 同一句代码,传入不同的对象,执行不同的行为,这就是多态
    Buy(p);  // 传入普通人对象,调用Person::BuyTicket
    Buy(s);  // 传入学生对象,调用Student::BuyTicket
    Buy(so); // 传入军人对象,调用Soldier::BuyTicket

    return 0;
}

程序运行输出:

cpp 复制代码
普通人买票:全价
学生买票:半价优惠
军人买票:优先购票

可以看到,Buy函数中只有一句people.BuyTicket(),但传入不同的对象,就执行了不同的函数逻辑,完美实现了 "同一种行为,不同结果" 的多态特性。

2.4 多态调用的核心特点

多态的调用,不看指针 / 引用本身的类型,只看指针 / 引用指向的对象的类型:

如果指向基类对象,就调用基类的虚函数;

如果指向派生类对象,就调用派生类重写的虚函数。

这和非多态的普通函数调用完全相反:普通函数调用,编译时就根据指针 / 引用的类型确定了调用的函数,和指向的对象无关。

三、虚函数重写的特殊场景与语法细节

3.1 协变(了解即可)

协变是虚函数重写三要素的唯一例外:派生类重写基类虚函数时,返回值类型可以不同,但必须是父子类关系的指针或引用。

具体来说:基类虚函数返回基类对象的指针 / 引用,派生类虚函数返回派生类对象的指针 / 引用,这种情况依然构成重写,称为协变。

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

class Person {
public:
    // 基类虚函数返回A*
    virtual A* BuyTicket() {
        cout << "普通人买票:全价" << endl;
        return nullptr;
    }
};

class Student : public Person {
public:
    // 派生类虚函数返回B*,B是A的派生类,构成协变,依然是重写
    virtual B* BuyTicket() override {
        cout << "学生买票:半价" << endl;
        return nullptr;
    }
};

协变在实际开发中使用极少,我们只需要了解这个语法规则即可,面试中偶尔会作为选择题考点出现。

3.2 析构函数的重写

这是开发中很重要的重写场景,基类的析构函数建议定义为虚函数。

为什么基类析构函数要加 virtual?

我们先看一个反面示例

cpp 复制代码
class A {
public:
    // 基类析构函数不加virtual
    ~A() {
        cout << "~A() 基类析构" << endl;
    }
};

class B : public A {
public:
    B() {
        // 派生类构造时申请内存
        _p = new int[10];
    }
    ~B() {
        cout << "~B() 派生类析构,释放资源" << endl;
        delete[] _p; // 派生类析构时释放资源
    }
private:
    int* _p;
};

int main() {
    A* p1 = new A;
    A* p2 = new B; // 基类指针指向派生类对象

    delete p1;
    delete p2; // 这里会发生内存泄漏!
    return 0;
}

上面的代码运行后,delete p2时,只会调用A的析构函数,不会调用B的析构函数,导致B中申请的内存无法释放,造成内存泄漏。

根本原因:基类析构函数不加 virtual,delete p2时不会触发多态,只会根据指针类型A*,调用A的析构函数。

解决方案:基类析构函数加 virtual

我们给基类析构函数加上virtual,问题就解决了:

cpp 复制代码
class A {
public:
    // 基类析构函数定义为虚函数
    virtual ~A() {
        cout << "~A() 基类析构" << endl;
    }
};

class B : public A {
public:
    B() { _p = new int[10]; }
    // 派生类析构函数,无论是否加virtual,都和基类构成重写
    ~B() {
        cout << "~B() 派生类析构,释放资源" << endl;
        delete[] _p;
    }
private:
    int* _p;
};

int main() {
    A* p2 = new B;
    delete p2; // 触发多态,先调用B的析构,再调用A的析构,资源完全释放
    return 0;
}

为什么析构函数名不同,还能构成重写?
编译器会对所有类的析构函数名做特殊处理,编译后统一处理成destructor(),所以基类和派生类的析构函数名看似不同,实际编译后是相同的,只要基类析构函数加了virtual,就会构成重写。
开发规范:只要一个类会被作为基类继承,它的析构函数就必须定义为虚函数。

3.3 C++11 辅助关键字:override 和 final

C++11 新增了两个关键字,专门用于辅助虚函数重写,解决了传统写法中 "重写失败编译不报错" 的痛点。

  1. override:检查是否完成重写
    override关键字加在派生类虚函数的末尾,强制编译器检查该函数是否重写了基类的虚函数。如果没有重写(比如函数名、参数写错了),编译器会直接报错,提前暴露问题,避免运行时才发现 bug。
cpp 复制代码
class Car {
public:
    virtual void Drive() {}
};

class Benz : public Car {
public:
    // 正确重写,编译通过
    virtual void Drive() override { cout << "奔驰:舒适驾驶" << endl; }

    // 错误示例:函数名写错,加了override会直接编译报错
    // virtual void Dirve() override { cout << "错误写法" << endl; }
};

2. final:禁止虚函数被重写 / 禁止类被继承

final关键字有两个核心用法:

加在基类虚函数的末尾,禁止该虚函数被派生类重写,派生类如果尝试重写,编译会直接报错;

加在类名的后面,禁止该类被继承,任何类尝试继承该类,编译会直接报错。

cpp 复制代码
// 用法1:禁止虚函数被重写
class Car {
public:
    // Drive函数被final修饰,派生类无法重写
    virtual void Drive() final {}
};

class Benz : public Car {
public:
    // 编译报错:final修饰的函数无法被重写
    // virtual void Drive() override {}
};

// 用法2:禁止类被继承
class Base final {};

// 编译报错:final修饰的类无法被继承
// class Derive : public Base {};

四、重载、重写、隐藏的区别

这三个概念很多初学者很容易混淆,我们用一张表格和通俗的解释彻底讲清楚三者的区别:

概念 作用域 核心要求 关键字要求
函数重载 同一个作用域内 函数名相同,参数列表(个数/类型/顺序)不同,与返回值无关 无要求
重写(覆盖) 继承体系的基类和派生类,两个不同作用域 1. 函数名、参数列表、返回值完全相同(协变例外) 2. 两个函数必须都是虚函数 基类必须加 virtual
隐藏(重定义) 继承体系的基类和派生类,两个不同作用域 1. 函数名相同,不构成重写的,都构成隐藏 2. 派生类和基类的同名成员变量,也构成隐藏 无要求
通俗记忆:
重载:同一个类里,同名函数,参数不同,就是重载;
重写:父子类里,虚函数,三要素完全相同,就是重写,是多态的前提;
隐藏:父子类里,同名函数 / 变量,不构成重写,就一定是隐藏,派生类会屏蔽基类的同名成员。

五、纯虚函数和抽象类

5.1 什么是纯虚函数?

在虚函数的声明后面加上=0,这个函数就叫做纯虚函数。纯虚函数一般只需要声明,不需要写函数实现(语法上可以写,但没有实际意义)

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

5.2 什么是抽象类?

包含纯虚函数的类,叫做抽象类(也叫接口类) 。抽象类有一个核心特性:不能实例化出对象

如果派生类继承了抽象类,但没有重写所有的纯虚函数,那么这个派生类也还是抽象类,依然不能实例化对象。只有派生类重写了所有的纯虚函数,才能实例化对象。

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

class Benz : public Car {
public:
    // 重写纯虚函数
    virtual void Drive() override {
        cout << "奔驰:舒适驾驶" << endl;
    }
};

class BMW : public Car {
public:
    // 重写纯虚函数
    virtual void Drive() override {
        cout << "宝马:操控驾驶" << endl;
    }
};

int main() {
    // Car car; // 编译报错:抽象类无法实例化对象
    Car* pBenz = new Benz; // 可以用抽象类的指针/引用指向派生类对象
    pBenz->Drive();

    Car* pBMW = new BMW;
    pBMW->Drive();

    return 0;
}

5.3 抽象类的核心作用

抽象类的核心价值,就是强制派生类必须重写接口,实现了接口定义和实现的分离,也就是面向接口编程

抽象类只定义 "类应该有什么行为",不关心行为的具体实现,具体实现交给派生类去完成。比如上面的Car抽象类,只定义了 "汽车必须有驾驶这个行为",但具体是舒适驾驶还是操控驾驶,交给奔驰、宝马这些派生类自己实现。这是 C++ 实现多态、高扩展性代码的核心设计思想。

六、多态的底层原理:虚函数表与动态绑定

很多初学者会好奇:多态到底是怎么实现的?为什么同一句代码,运行时能根据对象类型调用不同的函数?这一切的底层,都离不开虚函数表指针(vfptr)和虚函数表(虚表)

6.1 虚函数表指针(vfptr)

我们先看一个选择题:

cpp 复制代码
class Base {
public:
    virtual void Func1() {}
protected:
    int _b = 1;
    char _ch = 'x';
};

int main() {
    cout << sizeof(Base) << endl; // 32位程序下,输出结果是多少?
    return 0;
}

很多初学者会算:int 占 4 字节,char 占 1 字节,内存对齐后总共 8 字节。但实际 32 位程序下,运行结果是12 字节。

多出来的 4 字节,就是虚函数表指针__vfptr。只要一个类包含虚函数,它的对象中就会自动包含一个虚函数表指针,放在对象内存布局的最开头(不同平台可能有差异,VS 系列编译器放在开头),这个指针指向一张虚函数表。

6.2 虚函数表(虚表)

虚函数表,本质是一个存储虚函数指针的数组 ,一个类的所有虚函数的地址,都会被放到这个类对应的虚表中。

核心规则:
同类型的对象,共享同一张虚表 :同一个类的所有对象,虚函数表指针都指向同一张虚表;
基类和派生类有各自独立的虚表 :即使是继承关系,基类和派生类也有自己的虚表,不会共用;
重写会覆盖虚表中的函数地址 :如果派生类重写了基类的虚函数,派生类虚表中对应的函数地址,会被替换成派生类重写的函数地址;没有重写的虚函数,直接继承基类的地址;

虚表的末尾,一般会有一个nullptr作为结束标记(不同编译器实现略有差异)。

我们用一个示例看基类和派生类的虚表区别:

cpp 复制代码
class Base {
public:
    virtual void func1() { cout << "Base::func1" << endl; }
    virtual void func2() { cout << "Base::func2" << endl; }
protected:
    int a = 1;
};

class Derive : public Base {
public:
    // 重写基类的func1
    virtual void func1() override { cout << "Derive::func1" << endl; }
    // 派生类自己的虚函数func3
    virtual void func3() { cout << "Derive::func3" << endl; }
protected:
    int b = 2;
};
基类 Base 的虚表 派生类 Derive 的虚表
&Base::func1 &Derive::func1(重写后,覆盖了基类的地址)
&Base::func2 &Base::func2(未重写,继承基类地址)
结束标记 nullptr &Derive::func3(派生类自己的虚函数)
结束标记 nullptr

6.3 多态的本质:动态绑定与静态绑定

理解了虚表,我们就能彻底搞懂多态的实现原理,核心就是动态绑定和静态绑定

  1. 静态绑定(编译时绑定)
    不满足多态条件的函数调用,都是静态绑定。编译器在编译阶段,就直接确定了要调用的函数地址 ,编译后就固定死了,运行时不会改变。
    比如普通函数调用、对象调用虚函数、非虚函数调用,都是静态绑定。
  2. 动态绑定(运行时绑定)
    满足多态条件的函数调用(基类指针 / 引用调用虚函数),就是动态绑定。编译器在编译阶段,无法确定要调用哪个函数,只有程序运行时,通过指针 / 引用指向的对象的虚表,找到对应的虚函数地址,才完成函数调用
    我们看底层汇编代码,就能清晰看到区别:
cpp 复制代码
// 多态调用:动态绑定
void Buy(Person* ptr) {
    ptr->BuyTicket();
    // 底层汇编逻辑:
    // 1. 从ptr指向的对象中,取出虚表指针vfptr
    // 2. 从虚表中取出对应虚函数的地址
    // 3. 调用这个虚函数
}

这就是多态的核心原理:运行时,根据指向的对象,找到对应的虚表,再从虚表中找到要调用的虚函数,实现了不同对象执行不同行为。

6.4 常见问题答疑

虚函数存在哪里?

虚函数和普通函数一样,编译后是一段二进制指令,存储在代码段(常量区) ,只是它的地址被存到了虚表中。
虚函数表存在哪里?

C++ 标准没有明确规定,但主流编译器(VS、GCC)都把虚表存储在代码段(常量区) ,属于只读数据,程序运行期间不会修改。
inline 函数可以是虚函数吗?

语法上可以给 inline 函数加 virtual,但 inline 是编译时展开,虚函数是运行时动态绑定,二者是矛盾的。编译器会忽略 inline 属性,因为多态调用无法在编译时确定函数,无法展开。
静态成员函数可以是虚函数吗?

不可以。静态成员函数属于整个类,没有 this 指针,无法访问对象的虚表指针,无法实现动态绑定。

相关推荐
workflower3 小时前
AI制造-推荐初始步骤
java·开发语言·人工智能·软件工程·制造·需求分析·软件需求
zc.ovo3 小时前
河北师范大学2026校赛题解(A,E,I)
c++·算法
魔都吴所谓3 小时前
【Python】从零构建:IP地理位置查询实战指南
开发语言·python·tcp/ip
环黄金线HHJX.4 小时前
【吧里BaLi社区】
开发语言·人工智能·qt·编辑器
学嵌入式的小杨同学4 小时前
STM32 进阶封神之路(三十九)FreeRTOS 临界区、挂起 / 删除、钩子函数、调度底层原理|从应用到内核深度解析
c++·stm32·单片机·嵌入式硬件·mcu·硬件架构·pcb
oioihoii4 小时前
Cursor根本无法调试C++
开发语言·c++
GISer_Jing4 小时前
Agent多代理架构:子代理核心机制解密
开发语言·人工智能·架构·aigc
jie188945758665 小时前
c语言------
c语言·开发语言