C++ 多态:从概念到底层原理,彻底掌握动态行为

        多态是 C++ 面向对象编程(OOP)的三大核心特性之一(封装、继承、多态),它允许 “同一行为在不同对象上产生不同结果”,是实现代码灵活性与可扩展性的关键。例如 “买票” 行为,普通人全价、学生打折、军人优先 —— 这就是多态的直观体现。本文将从 “概念→实现条件→底层原理” 的路径,系统讲解 C++ 多态的核心机制,帮你彻底理解 “为什么基类指针能调用派生类函数”。

一、多态的概念:编译时与运行时的区别

多态本质是 “同一接口,多种实现”,但根据 “行为确定时机” 的不同,可分为两类:

多态类型实现方式确定时机示例
编译时多态(静态多态)函数重载、函数模板编译期add(1,2)(int 相加)与add(1.0,2.0)(double 相加)
运行时多态(动态多态)虚函数 + 继承运行期基类指针指向不同对象,调用同一虚函数产生不同行为

本文重点讲解运行时多态—— 它是 C++ 多态的核心,也是面试高频考点。

二、多态的实现条件:三大核心要素

要实现运行时多态,必须满足三个严格条件,缺一不可:

  1. 继承关系:派生类必须继承自基类;
  2. 虚函数重写:派生类必须重写基类的虚函数(函数名、参数列表、返回值完全一致,协变除外);
  3. 基类指针 / 引用调用:必须通过基类的指针或引用调用虚函数。

2.1 关键概念 1:虚函数(Virtual Function)

虚函数是多态的 “开关”—— 在类成员函数前加virtual关键字,该函数即成为虚函数。注意:非成员函数(如全局函数)、静态成员函数(static)、构造函数不能被定义为虚函数;析构函数可以(且建议)定义为虚函数。

代码示例(虚函数定义):

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

2.2 关键概念 2:虚函数重写(覆盖)

虚函数重写(也称 “覆盖”)是指:派生类中有一个与基类完全相同的虚函数(函数名、参数列表、返回值类型必须一致),派生类的虚函数会 “覆盖” 基类的虚函数。

重写的严格规则:

        函数名、参数列表、返回值必须完全一致(协变除外,下文讲解);

        基类函数必须加virtual,派生类函数可加可不加(但建议加,保证代码规范性);

        访问限定符(public/protected)可不同(如基类public,派生类protected),不影响重写。

代码示例(虚函数重写):

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

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

class Soldier : public Person {
public:
    void BuyTicket() { // 重写基类虚函数(不加virtual也可,但不推荐)
        cout << "军人买票:优先" << endl;
    }
};

2.3 关键概念 3:基类指针 / 引用调用

只有通过基类的指针或引用调用虚函数,才能触发多态 —— 若直接用派生类对象调用,无法体现多态特性(编译时已确定函数地址)。

代码示例(多态触发):

// 用基类指针调用虚函数
void Func(Person* ptr) {
    ptr->BuyTicket(); // 行为由ptr指向的对象决定,而非指针类型
}

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

    Func(&p);  // 指向基类对象,调用Person::BuyTicket → 输出“全价”
    Func(&s);  // 指向派生类对象,调用Student::BuyTicket → 输出“半价”
    Func(&sol); // 指向派生类对象,调用Soldier::BuyTicket → 输出“优先”
    return 0;
}

为什么必须用指针 / 引用?因为派生类对象赋值给基类对象时会发生 “切片”(仅复制基类部分),导致派生类独有的虚表信息丢失;而指针 / 引用不会复制对象,仅指向对象的内存地址,能完整访问对象的虚表(下文讲解虚表原理)。

三、虚函数重写的特殊情况

3.1 协变(Covariant):返回值可不同的特例

通常情况下,虚函数重写要求返回值类型完全一致,但协变允许返回值类型不同 —— 基类虚函数返回基类对象的指针 / 引用,派生类虚函数返回派生类对象的指针 / 引用。

代码示例(协变):

// 基类A和派生类B
class A {};
class B : public A {};

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

class Student : public Person {
public:
    // 派生类虚函数返回B*(协变,合法重写)
    virtual B* BuyTicket() {
        cout << "学生买票:半价" << endl;
        return nullptr;
    }
};

注意:协变的实际应用场景极少,了解即可,无需深入。

3.2 析构函数的重写:避免内存泄漏

基类的析构函数若不加virtual,派生类的析构函数与基类析构函数构成 “隐藏”(而非重写),当用基类指针删除派生类对象时,仅会调用基类析构函数,导致派生类独有的资源未释放(内存泄漏)。

解决方案:将基类析构函数定义为虚函数,派生类析构函数会自动重写(编译器会将析构函数名统一处理为destructor())。

代码示例(析构函数重写):

class A {
public:
    virtual ~A() { // 基类虚析构
        cout << "~A()" << endl;
    }
};

class B : public A {
public:
    B() { _p = new int[10]; } // 派生类申请资源
    ~B() { // 自动重写基类虚析构
        delete[] _p;
        cout << "~B()" << endl;
    }
private:
    int* _p;
};

int main() {
    A* p1 = new A;
    A* p2 = new B;

    delete p1; // 调用~A() → 正确
    delete p2; // 先调用~B(),再调用~A() → 资源全部释放,无泄漏
    return 0;
}

面试高频考点:为什么基类析构函数建议定义为虚函数?答:为了保证用基类指针删除派生类对象时,能完整调用派生类析构函数,避免内存泄漏。

3.3 override 与 final:C++11 的重写检查

虚函数重写要求严格,若因疏忽写错函数名(如BuyTickit)或参数列表,编译器不会报错,但运行时无法触发多态。C++11 提供两个关键字解决此问题:

关键字作用示例
override修饰派生类虚函数,强制检查是否重写基类虚函数(未重写则编译报错)virtual void BuyTicket() override;
final修饰基类虚函数,禁止派生类重写该函数;也可修饰类,禁止类被继承virtual void BuyTicket() final;

代码示例(override 检查):

class Car {
public:
    virtual void Drive() {} // 基类虚函数
};

class Benz : public Car {
public:
    // 错误:函数名写错(Dirve≠Drive),override检查到未重写,编译报错
    virtual void Dirve() override {
        cout << "Benz:舒适" << endl;
    }
};

代码示例(final 禁止重写):

class Car {
public:
    virtual void Drive() final {} // 禁止派生类重写
};

class Benz : public Car {
public:
    // 错误:Drive被final修饰,无法重写,编译报错
    virtual void Drive() {
        cout << "Benz:舒适" << endl;
    }
};

四、纯虚函数与抽象类

在虚函数后加=0,该函数即为纯虚函数;包含纯虚函数的类称为抽象类(也叫接口类)。

4.1 抽象类的特性:

  1. 抽象类不能实例化对象(编译报错);
  2. 派生类必须重写抽象类的所有纯虚函数,否则派生类也是抽象类(无法实例化);
  3. 抽象类的指针 / 引用可以指向派生类对象(用于多态)。

代码示例(纯虚函数与抽象类):

// 抽象类:包含纯虚函数Drive()
class Car {
public:
    virtual void Drive() = 0; // 纯虚函数
};

// 派生类Benz:重写纯虚函数,可实例化
class Benz : public Car {
public:
    virtual void Drive() {
        cout << "Benz:舒适" << endl;
    }
};

// 派生类BMW:重写纯虚函数,可实例化
class BMW : public Car {
public:
    virtual void Drive() {
        cout << "BMW:操控" << endl;
    }
};

int main() {
    // Car c; // 错误:抽象类不能实例化对象

    Car* pBenz = new Benz; // 抽象类指针指向派生类对象
    Car* pBMW = new BMW;

    pBenz->Drive(); // 调用Benz::Drive → 输出“舒适”
    pBMW->Drive();  // 调用BMW::Drive → 输出“操控”

    delete pBenz;
    delete pBMW;
    return 0;
}

4.2 抽象类的用途:

        强制派生类实现接口:纯虚函数相当于 “接口规范”,派生类必须按规范实现功能(如Car抽象类强制所有车型实现Drive方法);

        实现多态的基础:抽象类的指针 / 引用是多态调用的 “统一入口”,便于代码扩展(新增车型时,无需修改原有调用逻辑)。

五、多态的底层原理:虚表与虚表指针

多态的本质是通过 “虚表(Virtual Table) ” 和 “虚表指针(Virtual Table Pointer, vfptr) ” 实现的 —— 这是理解多态的关键,也是面试必问考点。

5.1 虚表指针(vfptr):对象的 “多态开关”

当类中包含虚函数时,编译器会在该类的每个对象中自动添加一个隐藏的成员变量 —— 虚表指针(vfptr),它指向一张 “虚函数表”(简称虚表)。

验证虚表指针的存在

class Base {
public:
    virtual void Func1() {} // 虚函数
protected:
    int _b = 1; // 成员变量
    char _ch = 'x';
};

int main() {
    Base b;
    cout << sizeof(b) << endl; // 32位系统输出12,64位输出16
    return 0;
}

结果分析

        32 位系统中,int(4 字节)+ char(1 字节,内存对齐后 4 字节)= 8 字节,但实际sizeof(b)=12—— 多出来的 4 字节就是虚表指针(vfptr);

        64 位系统中,虚表指针占 8 字节,内存对齐后总大小为 16 字节。

5.2 虚表(vtable):存储虚函数地址的数组

虚表是编译器为每个包含虚函数的类生成的一张 “函数地址表”,本质是一个存储虚函数指针的数组(数组末尾通常有一个nullptr标记,不同编译器可能不同)。

虚表的生成规则:
  1. 基类的虚表:存储基类所有虚函数的地址;
  2. 派生类的虚表:
    • 先复制基类虚表的所有内容;
    • 若派生类重写了基类的某个虚函数,用派生类虚函数的地址 “覆盖” 虚表中对应的基类虚函数地址;
    • 若派生类有自己的新虚函数,将其地址添加到虚表末尾。
虚表的位置:

虚表是 “类级别的资源”,所有同类型对象共用一张虚表,存储在代码段(常量区)(而非堆或栈)—— 可通过内存地址对比验证(虚表地址与字符串常量地址接近)。

5.3 多态的实现流程:动态绑定

当通过基类指针 / 引用调用虚函数时,编译器会触发 “动态绑定”(运行时确定函数地址),具体流程如下:

  1. 从基类指针 / 引用指向的对象中,获取虚表指针(vfptr);
  2. 通过虚表指针找到对应的虚表;
  3. 在虚表中找到要调用的虚函数的地址;
  4. 调用该地址对应的虚函数。

示例图解(以PersonStudent类为例):

  Person对象的vfptr指向Person的虚表,虚表中存储Person::BuyTicket的地址;

  Student对象的vfptr指向Student的虚表,虚表中Person::BuyTicket的地址已被Student::BuyTicket覆盖;

        当Func(&s)调用时,通过Student对象的vfptr找到Student的虚表,调用Student::BuyTicket

5.4 动态绑定 vs 静态绑定

        静态绑定:函数地址在编译时确定,适用于非虚函数调用(如普通函数、静态函数);

        动态绑定:函数地址在运行时确定,适用于虚函数调用(通过基类指针 / 引用)。

汇编代码对比

// 动态绑定(虚函数调用)
ptr->BuyTicket();
// 汇编关键指令:从对象中取vfptr → 从虚表中取函数地址 → 调用
00 EF2001 mov eax,dword ptr [ptr]    // eax = 对象地址(含vfptr)
00 EF2004 mov edx,dword ptr [eax]    // edx = vfptr(虚表地址)
00 EF200B mov eax,dword ptr [edx]    // eax = 虚表中第一个虚函数地址
00 EF200D call eax                   // 调用虚函数

// 静态绑定(非虚函数调用)
ptr->BuyTicket();
// 汇编关键指令:直接调用固定地址的函数
00 EA2C94 call Person::BuyTicket (0EA153Ch)

六、易混淆概念对比:重载、重写、隐藏

C++ 中函数的 “同名现象” 有三种:重载、重写、隐藏,极易混淆,需明确区分:

特性重载(Overload)重写(Override)隐藏(Hide)
作用域同一类(或同一作用域)基类与派生类(不同作用域)基类与派生类(不同作用域)
函数名相同相同相同
参数列表必须不同(类型 / 个数 / 顺序)必须相同可相同可不同
返回值可相同可不同必须相同(协变除外)可相同可不同
虚函数要求基类必须是虚函数,派生类可加
调用方式编译时确定(静态绑定)运行时确定(动态绑定,需指针 / 引用)编译时确定(静态绑定)

示例对比

class A {
public:
    // 重载:同一作用域,函数名相同,参数不同
    void func(int x) {}
    void func(double x) {}

    // 虚函数:用于重写
    virtual void show() {}
};

class B : public A {
public:
    // 重写:基类虚函数,函数名、参数、返回值相同
    virtual void show() {}

    // 隐藏:函数名相同,不构成重写(基类func非虚函数)
    void func(int x) {}
};

七、总结

多态是 C++ 面向对象编程的灵魂,其核心要点可总结为:

  1. 实现条件:继承 + 虚函数重写 + 基类指针 / 引用调用;
  2. 关键概念:虚函数(virtual)、纯虚函数(=0)、抽象类(含纯虚函数)、虚表(存储虚函数地址)、虚表指针(对象指向虚表的指针);
  3. 底层原理:通过虚表指针找到虚表,运行时动态绑定虚函数地址,实现 “同一接口,多种行为”;
  4. 实战建议:基类析构函数务必定义为虚函数,用override检查重写,优先用抽象类定义接口规范。
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值