C++多态

1. 多态的定义及实现

1.1 多态的构成条件

多态是指在继承关系下,不同的类对象调用同一函数时,产生了不同的行为。

示例
Student 类继承了 Person 类。

  • Person 对象买票:全价
  • Student 对象买票:优惠
1.2 实现多态的两个必要条件

要实现多态效果,必须同时满足以下两个关键条件:

  1. 调用方式限制

    必须是基类的指针 或者引用来调用虚函数。

    • 原因:只有基类的指针或引用才能既指向基类对象,又指向派生类对象,从而实现统一接口下的不同表现。
  2. 函数定义限制

    被调用的函数必须是虚函数 ,并且派生类必须完成对该虚函数的重写(覆盖)

    • 原因:只有完成了重写/覆盖,基类和派生类之间才能拥有不同的函数实现逻辑,从而达到多态的"不同形态"效果。
2 虚函数

在类成员函数前面加上 virtual 修饰符,该成员函数即被称为虚函数

注意 :非成员函数(如全局函数、静态成员函数)不能加 virtual 修饰。

代码示例:

cpp 复制代码
class Person 
{
public:
    // 使用 virtual 关键字声明虚函数
    virtual void BuyTicket() 
    { 
        cout << "买票-全价" << endl;
    }
};
2.1 虚函数的重写/覆盖

定义

当派生类中定义了一个与基类完全相同 的虚函数时,称为派生类重写(或覆盖)了基类的虚函数。

重写的严格条件

派生类的虚函数必须与基类虚函数在以下三个方面完全一致

  1. 返回值类型相同
  2. 函数名字相同
  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* (构成协变)
    virtual B* BuyTicket() 
    { 
        cout << "买票-打折" << endl;
        return nullptr;
    }
};

void Func(Person* ptr)
{
    ptr->BuyTicket(); // 多态调用
}

int main()
{
    Person ps;
    Student st;
    Func(&ps);
    Func(&st);
    return 0;
}

2. 析构函数的重写

规则

如果基类的析构函数是虚函数,那么派生类的析构函数:

  • 无论是否显式添加 virtual 关键字;
  • 只要定义了该析构函数;

它都会自动 与基类的析构函数构成重写

原理

虽然基类和派生类的析构函数在源码中名字不同(例如 ~A()~B()),表面上看不符合"函数名相同"的重写规则,但实际上:

  • 编译器对析构函数的名称做了特殊处理
  • 在编译后,所有析构函数的名称被统一处理为 destructor
  • 因此,只要基类析构函数加了 virtual 修饰,派生类的析构函数在底层逻辑上就构成了重写。
重要性

核心问题

如果基类的析构函数不是 虚函数,当通过基类指针 删除派生类对象时:

  1. 只会调用基类的析构函数。
  2. 不会调用派生类的析构函数。

后果

这会导致派生类中申请的资源(如动态分配的内存、打开的文件句柄等)无法被释放,从而造成严重的内存泄漏

最佳实践

在设计类继承体系时,基类的析构函数必须设计为虚函数。这是保证多态删除对象时资源正确释放的关键。

override和final关键字

从上⾯可以看出,C++对虚函数重写的要求⽐较严格,但是有些情况下由于疏忽,⽐如函数名写错、参数写错等导致⽆法构成重写,⽽这种错误在编译期间是不会报出的。只有在程序运⾏时没有得到预期结果才来debug会得不偿失。

因此,C++11提供了 override ,可以帮助⽤⼾检测是否重写。如果我们不想让派⽣类重写这个虚函数,那么可以⽤ final 去修饰。

1. override 关键字
  • 作用:显式地指示编译器,该虚函数旨在重写基类中的同名虚函数。
  • 优势:如果派生类中的函数签名(函数名、参数列表、const属性等)与基类中的虚函数不匹配,编译器会直接报错。这有助于在编译期捕获重写错误,避免运行时出现逻辑错误。
2. final 关键字
  • 作用:禁止派生类重写该虚函数。
  • 应用场景 :当你希望某个虚函数在继承体系中保持特定实现,不允许派生类修改其行为时,可以使用 final
3. 示例代码
cpp 复制代码
#include <iostream>

class Base {
public:
    virtual void func1() {
        std::cout << "Base::func1" << std::endl;
    }
    virtual void func2() final { // 禁止派生类重写func2
        std::cout << "Base::func2" << std::endl;
    }
};

class Derived : public Base {
public:
    void func1() override { // 正确:重写Base::func1
        std::cout << "Derived::func1" << std::endl;
    }
    // void func2() override { // 错误:尝试重写final函数,编译器会报错
    //     std::cout << "Derived::func2" << std::endl;
    // }
    void func3() override { // 错误:Base中没有func3,编译器会报错
        std::cout << "Derived::func3" << std::endl;
    }
};

int main() {
    Derived d;
    d.func1(); // 输出: Derived::func1
    d.func2(); // 输出: Base::func2
    return 0;
}

重载、重写与隐藏的对比

概念 作用域 函数名 参数列表 返回值 关键字/特殊要求
重载 同一作用域 (同一个类中) 相同 不同 (类型或个数不同) 无关 (可同可不同) 无特殊要求
重写/覆盖 不同作用域 (父类与子类) 相同 必须相同 必须相同 (协变例外) 基类函数必须有 virtual 关键字
隐藏 不同作用域 (父类与子类) 相同 不同 (若相同且无 virtual 也属隐藏) 无关 只要不构成重写,同名即隐藏

补充说明:

  • 隐藏还有一种情况:子类成员变量与父类成员变量同名,也称为隐藏。
  • 协变:指重写时返回值可以是父类虚函数返回值的派生类指针或引用。

3. 纯虚函数和抽象类

在虚函数的后面写上 =0,则这个函数为纯虚函数。纯虚函数不需要定义实现(虽然语法上允许实现,但通常没有意义,因为会被派生类重写),只要声明即可。

  • 抽象类:包含纯虚函数的类叫做抽象类(也叫接口类)。
  • 实例化限制 :抽象类不能实例化出对象
  • 派生类规则
    • 如果派生类继承后不重写纯虚函数,那么该派生类也是抽象类,同样无法实例化对象。
    • 纯虚函数强制了派生类必须重写该虚函数,否则无法生成实例。
示例代码
cpp 复制代码
class AbstractClass {
public:
    virtual void func() = 0; // 纯虚函数
};

class DerivedClass : public AbstractClass {
public:
    void func() override { // 必须重写纯虚函数
        std::cout << "DerivedClass::func" << std::endl;
    }
};

int main() {
    // AbstractClass a; // 错误:抽象类不能实例化
    DerivedClass d;      // 正确:重写了纯虚函数,可以实例化
    d.func();
    return 0;
}

4.多态的原理

什么是虚函数表指针 (vptr)?

简单来说,vptr 是一个隐藏在类对象内部的指针。

  • 归属 :它属于对象(实例)。
  • 作用 :它指向该对象所属类的 虚函数表(vtable)
  • 目的 :为了让程序在运行时,能够根据对象的实际类型,找到并调用正确的虚函数版本(即实现动态绑定 )。
    每个拥有虚函数的类,都会有一个属于自己的、独立的虚函数表。

底层工作原理

当一个类中声明了虚函数(virtual 函数)时,编译器会自动做两件事:

  1. 生成虚函数表(vtable)
    • 这是一个静态 的数组,属于,所有该类的对象共享一张表。
    • 表中存放了该类所有虚函数的地址。
  2. 插入虚函数表指针(vptr)
    • 编译器会在类的每个对象的内存布局中,隐式插入一个指针,这就是 vptr。
    • 在对象构造时,vptr 会被初始化,指向该类的 vtable。
内存布局示意图

通常情况下(取决于编译器实现,如 GCC/MSVC),vptr 位于对象内存布局的最前端

内存偏移 内容 说明
0x00 vptr 指向虚函数表的指针 (通常占 4 或 8 字节)
0x08 成员变量 A 类中定义的其他数据
... ... ...

多态调用的过程

当你通过基类指针或引用调用虚函数时,底层发生了以下步骤:

  1. 访问 vptr :程序通过对象地址,读取位于首部的 vptr
  2. 查找 vtable :顺着 vptr 找到对应的虚函数表。
  3. 定位函数:在表中根据函数声明的顺序(偏移量),找到对应的函数地址。
  4. 调用:跳转到该地址执行代码。

核心逻辑 :因为派生类对象的 vptr 指向的是派生类的虚表,所以即使你用基类指针指向它,程序也能通过 vptr 找到派生类的函数实现。


初始化时机与构造顺序

这是一个非常关键且容易出错的细节。vptr 的初始化发生在构造函数执行之前(由编译器插入代码)。

继承体系下的变化

在构造派生类对象时,vptr 会经历"变色"过程:

  1. 基类构造阶段
    • 先执行基类构造函数。
    • 此时,对象的 vptr 被设置为指向 基类的 vtable
    • 注意:如果在基类构造函数中调用虚函数,调用的是基类版本,不会发生多态。
  2. 派生类构造阶段
    • 基类构造完成后,执行派生类构造函数。
    • 此时,vptr覆盖/修改 ,指向 派生类的 vtable

对对象大小的影响

由于 vptr 的存在,含有虚函数的类对象会比普通类对象大。

  • 普通类:大小 = 所有非静态成员变量之和。
  • 含虚函数的类 :大小 = 所有非静态成员变量之和 + 1个指针的大小
    • 32位 系统上,通常增加 4字节
    • 64位 系统上,通常增加 8字节

总结

特性 说明
名称 虚函数表指针 (_vptr, vfptr)
存储位置 对象的内存空间内(通常在头部)
指向目标 类的虚函数表 (vtable)
数量 每个含虚函数的对象包含 1个 vptr (单继承下)
主要代价 增加对象内存占用 (1个指针大小),以及运行时查表的微小时间开销

4.2 多态的原理

4.2.1 多态是如何实现的

从底层的角度来看,在 Func 函数中执行 ptr->BuyTicket() 时,系统是如何做到"指向 Person 对象就调用 Person::BuyTicket,指向 Student 对象就调用 Student::BuyTicket 的呢?

通过底层分析我们可以发现,满足多态条件后,函数的调用机制发生了根本变化:

  • 不再是编译时确定:底层不再是编译时通过调用对象的类型来确定函数的地址(静态绑定)。
  • 而是运行时动态查找:是在运行时,到指向的对象的**虚函数表(vtable)**中确定对应虚函数的地址(动态绑定)。

这样就实现了"指针或引用指向基类就调用基类的虚函数,指向派生类就调用派生类对应的虚函数"。

图解分析
  1. 指向基类对象(Person)

    • 场景ptr 指向 Person 对象。
    • 结果 :通过 ptr 中的 vptr 找到 Person 的虚表,调用的是 Person 的虚函数。
  2. 指向派生类对象(Student)

    • 场景ptr 指向 Student 对象。
    • 结果 :通过 ptr 中的 vptr 找到 Student 的虚表,调用的是 Student 的虚函数。


4.2.2 动态绑定与静态绑定

1. 静态绑定(Static Binding)
  • 定义 :也称为编译时绑定。是指在程序编译期间,就已经确定了函数调用的具体地址。
  • 触发条件 :针对不满足多态条件 的函数调用。
    • 例如:普通函数调用、函数重载、指针/引用调用非虚函数等。
  • 机制:编译器在编译阶段直接确定调用函数的地址,生成代码时直接跳转到该地址。
2. 动态绑定(Dynamic Binding)
  • 定义 :也称为运行时绑定。是指在程序运行期间,根据对象的实际类型来确定调用哪个函数。
  • 触发条件 :针对满足多态条件 的函数调用。
    • 必须同时满足:
      1. 通过指针 或者引用调用。
      2. 调用的是虚函数
  • 机制 :在运行时,通过对象内部的 vptr 找到其对应的虚函数表,并在表中查找并定位到具体调用函数的地址。

虚函数表(vtable)详解

1. 虚函数表的基本归属
  • 基类:基类对象的虚函数表中存放基类所有虚函数的地址。
  • 独立性 :同类型的对象共用同一张虚表,不同类型的对象各自有独立的虚表。因此,基类和派生类拥有各自独立的虚表
2. 派生类与虚表指针(vptr)
  • 构成:派生类由"继承下来的基类部分"和"自己的成员部分"构成。
  • vptr 的继承 :一般情况下,派生类会继承基类中的虚函数表指针,自己不会再生成新的虚表指针。
  • 独立性注意 :虽然继承了 vptr,但派生类对象中继承下来的这个 vptr,与独立基类对象的 vptr 是相互独立的(就像派生类中的基类成员变量与独立的基类对象也是独立的一样)。
3. 虚函数的覆盖与组成
  • 覆盖机制 :如果派生类重写了基类的虚函数,派生类虚函数表中对应的条目就会被覆盖,更新为派生类重写后的虚函数地址。
  • 虚表内容组成 :派生类的虚函数表中通常包含三部分:
    1. 基类的虚函数地址(未被重写的)。
    2. 派生类重写的虚函数地址(完成覆盖)。
    3. 派生类自己新增的虚函数地址。
4. 虚函数表的底层结构
  • 本质 :虚函数表本质上是一个存放虚函数指针的指针数组
  • 结束标记
    • VS 编译器 :通常会在数组最后放一个 0x00000000 作为结束标记。
    • GCC 编译器:通常不会放这个标记。
    • 注:C++ 标准并未规定必须有结束标记,这是编译器厂商的自行定义。
5. 内存存储位置
  • 虚函数(代码) :虚函数和普通函数一样,编译后是一段指令,存放在代码段 。虚表中存的只是它的地址
  • 虚函数表(数据)
    • C++ 标准并没有严格规定虚表存放在哪里。
    • VS 编译器下 :通常存放在代码段(常量区)
    • 注:不同编译器实现可能不同,需具体验证。
相关推荐
啦啦啦!2 小时前
项目环境的搭建,项目的初步使用和deepseek的初步认识
开发语言·c++·人工智能·算法
曼巴UE52 小时前
Unlua 官方案例
c++·ue5·lua·ue
鲸渔2 小时前
【C++ 变量与常量】变量的定义、初始化、const 与 constexpr
java·开发语言·c++
John_ToDebug2 小时前
Chrome 首次启动引导页里触发 Pref 设置,为什么主进程收不到 IPC
c++·chrome
我头发多我先学2 小时前
C++ STL vector 原理到模拟实现
c++·算法
鲸渔2 小时前
【C++ 入门】第一个程序:Hello World 与基本语法规则
开发语言·c++·算法
EverestVIP2 小时前
C++ 仿函数(Functors)
c++
会编程的土豆2 小时前
【数据结构与算法】 时间复杂度计算
数据结构·c++·算法
John_ToDebug2 小时前
Chromium 页面类型与 IPC 通信机制深度解析
前端·c++·chrome