C++ ——— 深入解析多态从语法到底层实现的完整知识体系

目录

多态的条件以及示例代码

一、多态的核心定义:同一行为,不同表现

[二、虚函数:实现动态多态的 "开关"](#二、虚函数:实现动态多态的 “开关”)

[三、虚函数重写(Override):多态的 "前提基础"](#三、虚函数重写(Override):多态的 “前提基础”)

四、形成动态多态的两个必要条件

五、代码中的多态执行流程分析

六、总结
虚函数的析构函数

一、析构函数的特殊处理:编译器统一重命名为destructor

二、不加virtual:父子类析构函数构成隐藏关系,导致析构不完整

三、加virtual:父子类析构函数构成重写,满足多态的两个条件

[四、代码执行流程分析(加virtual vs 不加virtual)](#四、代码执行流程分析(加virtual vs 不加virtual))

五、虚析构函数的核心价值

六、总结
抽象类与final和override关键字

[一、纯虚函数(Pure Virtual Function)](#一、纯虚函数(Pure Virtual Function))

二、final关键字的两个核心用法

三、override关键字的用法

四、总结
虚函数表(x86平台下,小端机)

一、先明确:虚函数表(vtable)的核心概念

[二、x86 小端机的内存存储规则(先理解地址的显示形式)](#二、x86 小端机的内存存储规则(先理解地址的显示形式))

[三、Person 类的内存与虚函数表分析](#三、Person 类的内存与虚函数表分析)

[四、Student 类的内存与虚函数表分析](#四、Student 类的内存与虚函数表分析)

[五、为什么 Student 虚函数表中:BuyTicket 地址变了,test 地址没变?](#五、为什么 Student 虚函数表中:BuyTicket 地址变了,test 地址没变?)

[六、func 函数中多态的底层执行过程(结合虚函数表)](#六、func 函数中多态的底层执行过程(结合虚函数表))

[七、总结:语法层 "重写" 与底层层 "覆盖" 的关系](#七、总结:语法层 “重写” 与底层层 “覆盖” 的关系)
打印虚函数表

[一、VS 中虚函数表末尾的空指针(0):遍历的终止依据](#一、VS 中虚函数表末尾的空指针(0):遍历的终止依据)

二、代码逐部分深度解析

三、代码的输出结果与底层逻辑

四、总结


多态的条件以及示例代码

复制代码
// 基类Person:定义人的通用行为(买票)
class Person
{
public:
    // 虚函数:用virtual修饰,实现多态的核心
    // const成员函数:表示该函数不修改类的成员变量,重写时也需保持const属性
    // 功能:普通人买票,全价
    virtual void BuyTicket() const
    {
        cout << "Person买票 - 全价" << endl;
    }
};

// 派生类Student:公有继承自Person,重写基类的虚函数
class Student : public Person
{
public:
    // 重写(Override):派生类对基类虚函数的重新实现
    // 要求:函数名、参数列表、返回值、const属性完全一致(C++11可加override关键字显式声明)
    // virtual可省略(派生类中该函数自动成为虚函数),但建议保留以增强可读性
    virtual void BuyTicket() const
    {
        cout << "Student买票 - 半价" << endl;
    }
};

// 通用函数:接收Person类型的const引用参数
// 核心:基类的引用/指针可以指向派生类对象,结合虚函数触发多态
void func(const Person& p)
{
    // 调用BuyTicket:若p绑定的是基类对象,调用基类版本;若绑定派生类对象,调用派生类版本
    // 该调用的函数版本在运行时确定(动态绑定),而非编译时(静态绑定)
    p.BuyTicket();
}

int main()
{
    Person p;   // 创建基类Person对象
    Student s;  // 创建派生类Student对象

    // 传递基类对象p:func的参数p绑定基类对象,调用Person::BuyTicket
    func(p);
    // 传递派生类对象s:func的参数p(Person&)绑定派生类对象s,调用Student::BuyTicket(多态体现)
    func(s);

    return 0;
}

一、多态的核心定义:同一行为,不同表现

多态是 C++ 面向对象三大特性(封装、继承、多态)的核心,指同一操作作用于不同的对象,会产生不同的执行结果。C++ 中的多态分为两类:

  • 静态多态(编译期多态) :由函数重载、模板实现,函数调用的版本在编译期 就确定(如Add(int, int)Add(double, double)的重载);
  • 动态多态(运行期多态) :由虚函数 + 继承 实现,函数调用的版本在运行期才确定(代码中核心体现的类型)。

代码中的多态表现:调用func(p)func(s)时,传入的是不同对象(Person/Student),p.BuyTicket()最终执行了不同的函数版本(基类 / 派生类),这就是动态多态的核心 ------"调用的函数版本由运行时绑定的对象类型决定,而非编译时的参数类型"

二、虚函数:实现动态多态的 "开关"

1. 虚函数的定义

虚函数是指virtual关键字修饰的类成员函数 ,其核心作用是打破编译期的静态绑定,让函数调用的版本延迟到运行期确定

复制代码
// 基类中的虚函数
virtual void BuyTicket() const { ... }
  • virtual仅需在基类声明虚函数时添加 ,派生类重写该函数时,virtual可省略(编译器会自动将派生类的重写函数视为虚函数),但建议保留以增强代码可读性;
  • 虚函数的本质是告诉编译器:"不要在编译期确定该函数的调用版本,留到运行期根据实际对象类型再决定"。

2. 虚函数的核心特性

  • 虚函数必须是类的非静态成员函数(静态成员函数属于类,而非对象,无法绑定到具体对象);
  • 虚函数可被派生类重写(Override),这是实现多态的基础;
  • 若基类的虚函数是const成员函数(如代码中BuyTicket() const),派生类重写时也必须保持const属性(属于重写规则的一部分)。

三、虚函数重写(Override):多态的 "前提基础"

虚函数重写是指派生类中定义了与基类虚函数 "原型完全一致" 的函数 ,是实现动态多态的必要前提(无重写则无多态)。

1. 重写的严格规则("三同 + 一可协变")

派生类的重写函数必须满足与基类虚函数:

  • 函数名相同 :如基类是BuyTicket,派生类也必须是BuyTicket
  • 参数列表完全相同 :参数的类型、个数、顺序一致(如基类是void BuyTicket() const,派生类不能是void BuyTicket(int) const);
  • 返回值类型相同 (或协变):普通虚函数要求返回值完全一致;若返回值是 "基类 / 派生类的指针 / 引用",则允许协变 (如基类返回Person*,派生类返回Student*);
  • const 属性相同 :基类虚函数是const成员函数,派生类重写时也必须加const(代码中核心细节)。

C++11 增强 :可在派生类重写函数后加override关键字,显式声明这是对基类虚函数的重写,若不满足重写规则,编译器会直接报错(如void BuyTicket() const override),避免手写错误。

2. 重写 vs 重载 vs 隐藏(易混淆概念对比)

概念 定义 作用域 匹配规则
重写(Override) 派生类重写基类的虚函数 不同作用域(基类 / 派生类) 函数原型完全一致
重载(Overload) 同一作用域的同名函数 同一作用域(类内 / 全局) 参数列表不同
隐藏(Hide) 派生类函数隐藏基类同名函数 不同作用域(基类 / 派生类) 函数名相同即可(无论参数)

代码中Student::BuyTicket() const是对Person::BuyTicket() const重写,而非重载 / 隐藏,这是触发多态的关键。

四、形成动态多态的两个必要条件

动态多态的实现必须同时满足以下两个条件,缺一不可:

条件 1:基类中必须定义虚函数,且派生类必须重写该虚函数

  • 若基类函数未加virtual(不是虚函数),即使派生类定义了同名函数,也只是隐藏而非重写,函数调用会被静态绑定(编译期确定);
  • 若派生类未重写基类虚函数,调用时会默认执行基类的虚函数版本,无法体现多态。

代码中:

  • 基类PersonBuyTicket是虚函数;
  • 派生类Student严格重写了该函数,满足条件 1。

条件 2:必须通过基类的指针或引用调用虚函数

这是实现动态多态的核心语法约束 ,也是最容易被忽视的点。若直接用基类对象 (值传递)调用虚函数,会触发切片,无法实现多态。

(1)为什么基类的指针 / 引用能触发多态?

基类的指针 / 引用不会拷贝派生类对象 ,而是直接指向 / 绑定派生类对象的内存 ,运行时能通过对象的虚函数表指针找到实际对象的虚函数版本。

代码中func的参数是const Person& p(基类引用):

  • 当传入Person p时,p绑定基类对象,调用基类的BuyTicket
  • 当传入Student s时,p绑定派生类对象的基类部分(无拷贝,仅引用),运行时能识别出实际对象是Student,调用派生类的BuyTicket

(2)为什么值传递(基类对象)无法触发多态?

若将func的参数改为值传递const Person p),传入Student s时会发生切片 :编译器将s中的基类部分拷贝到p中,p成为一个纯粹的Person对象(丢失派生类的所有信息)。此时调用p.BuyTicket(),无论传入的是Person还是Student,都会执行基类的虚函数版本,多态失效。

复制代码
// 值传递版本的func,多态失效
void func(const Person p) {
    p.BuyTicket(); // 无论传入Person还是Student,都调用Person::BuyTicket
}

五、代码中的多态执行流程分析

复制代码
int main() {
    Person p;   // 基类对象
    Student s;  // 派生类对象

    func(p);    // 步骤1:基类引用绑定基类对象
    func(s);    // 步骤2:基类引用绑定派生类对象
    return 0;
}
  1. 调用func(p)

    • func的参数const Person& p绑定基类对象p
    • 运行时,通过p的虚表指针找到Person的虚函数表,执行Person::BuyTicket(),打印Person买票 - 全价
  2. 调用func(s)

    • func的参数const Person& p绑定派生类对象s(基类引用指向派生类对象);
    • 运行时,通过s的虚表指针找到Student的虚函数表,执行Student::BuyTicket(),打印Student买票 - 半价

整个过程中,p.BuyTicket()的调用版本不是由编译期的Person&类型决定 ,而是由运行期绑定的实际对象类型Person/Student)决定,这就是动态多态的核心。

六、总结

  1. 多态的本质:动态多态是 "运行期根据实际对象类型,选择虚函数版本" 的机制,体现 "同一行为,不同对象的不同表现";
  2. 虚函数的作用 :是实现动态多态的核心开关,用virtual修饰后,函数调用从编译期静态绑定延迟到运行期动态绑定;
  3. 虚函数重写:派生类必须严格遵循 "三同" 规则重写基类虚函数,这是多态的前提;
  4. 多态的两个必要条件
    • 基类虚函数被派生类重写;
    • 通过基类的指针 / 引用调用虚函数(值传递会触发切片,多态失效)。

虚函数的析构函数

复制代码
// 基类Person:定义人的通用行为(买票),重点演示虚析构函数的作用
class Person
{
public:
    // 虚函数:买票行为,体现多态的基础示例
    virtual void BuyTicket() const
    {
        cout << "Person买票 - 全价" << endl;
    }

    // 虚析构函数:用virtual修饰,使父子类析构函数构成多态
    // 关键:C++编译器会将所有析构函数统一处理为名为「destructor」的函数,与函数名~Person/~Student无关
    virtual ~Person()
    {
        cout << "~Person()" << endl;
    }
};

// 派生类Student:公有继承自Person,重写基类的虚函数(包括析构函数)
class Student : public Person
{
public:
    // 重写基类的虚函数BuyTicket,体现普通虚函数的多态
    virtual void BuyTicket() const
    {
        cout << "Student买票 - 半价" << endl;
    }

    // 虚析构函数:派生类的析构函数会自动继承基类的virtual属性(也可省略virtual,建议保留增强可读性)
    // 编译器同样将其处理为名为「destructor」的函数,与基类的destructor构成重写关系
    virtual ~Student()
    {
        cout << "~Student()" << endl;
    }
};

int main()
{
    // 1. 创建Person类对象,用基类指针p指向该对象
    Person* p = new Person;
    
    // 2. delete基类指针p(指向基类对象):
    //    delete操作会执行两个步骤:
    //    步骤1:调用p->destructor()(即Person的析构函数,因p指向Person对象)
    //    步骤2:调用operator delete(p)释放堆内存
    delete p;

    // 3. 将基类指针p重新指向派生类Student对象(基类指针可指向派生类对象,体现类型兼容)
    p = new Student;
    
    // 4. delete基类指针p(指向派生类对象):
    //    由于析构函数是虚函数,触发多态析构:
    //    步骤1:调用p->destructor()(实际调用Student的析构函数,执行完后自动调用基类Person的析构函数)
    //    步骤2:调用operator delete(p)释放堆内存
    delete p;

    return 0;
}

一、析构函数的特殊处理:编译器统一重命名为destructor

C++ 中析构函数的语法名是~类名()(如~Person()~Student()),但编译器会将所有类的析构函数统一处理为内部名称为destructor的函数------ 这是析构函数的一个关键特性,也是父子类析构函数能构成 "重写" 的前提。

为什么要做这个统一处理?

  • 普通函数的重写要求函数名完全一致 ,而析构函数的语法名随类名变化(~Person~Student),编译器通过统一重命名为destructor,让父子类的析构函数具备了 "函数名相同" 的重写基础;
  • 这个处理是编译器的底层行为,对程序员透明,但直接决定了析构函数的重写 / 隐藏规则。

简单说:无论写的是~Person()还是~Student(),编译器眼里它们都是名为destructor的函数,这是析构函数与普通虚函数的核心差异点。

二、不加virtual:父子类析构函数构成隐藏关系,导致析构不完整

如果基类的析构函数不加virtual,父子类的析构函数(底层都是destructor)会触发隐藏规则(派生类的同名函数隐藏基类的同名函数,作用域不同),而非重写。此时会引发严重问题:

1. 隐藏关系的本质

隐藏的规则是:不同作用域(基类 / 派生类)的同名函数,无论参数是否一致,派生类函数都会隐藏基类函数 。由于析构函数被统一重命名为destructor,派生类的destructor会隐藏基类的destructor,编译器会按静态绑定(编译期确定调用版本)处理析构函数的调用。

2. 具体后果:派生类析构函数无法被调用

当用基类指针指向派生类对象delete时,编译器会根据指针的静态类型(基类) 调用基类的析构函数,而派生类的析构函数完全被忽略。如果派生类中有动态分配的资源(如new的数组、指针),这些资源会因派生类析构未执行而泄漏。

以代码为例,若去掉virtual修饰析构函数:

复制代码
// 基类析构无virtual
~Person() { cout << "~Person()" << endl; }
// 派生类析构无virtual
~Student() { cout << "~Student()" << endl; }

main函数中执行delete pp指向Student对象)时,只会调用Person的析构函数,输出两次~Person(),而Student的析构函数从未执行 ------ 这就是隐藏关系导致的析构不完整。

三、加virtual:父子类析构函数构成重写,满足多态的两个条件

给基类析构函数加virtual后,析构函数成为虚函数 ,此时父子类的析构函数(底层destructor)会构成重写,并满足动态多态的两个必要条件,最终实现 "基类指针指向派生类对象时,正确调用派生类析构"。

条件 1:基类定义虚函数,派生类重写该虚函数

  • 基类Person的析构函数被virtual修饰,成为虚函数;
  • 派生类Student的析构函数因编译器统一重命名为destructor,与基类虚函数的函数名、参数列表(析构无参数)、返回值(析构无返回值) 完全一致,满足重写规则(C++ 中析构函数的重写是特殊的,无需手动匹配参数 / 返回值);
  • 派生类的析构函数会自动继承基类的virtual属性(即使省略virtual关键字,依然是虚函数)。

条件 2:通过基类的指针 / 引用调用虚函数

delete基类指针的操作包含两个核心步骤:

  1. 调用指针指向对象的destructor函数(即p->destructor());
  2. 调用operator delete(p)释放堆内存。

其中第一步p->destructor() 正是通过基类指针调用虚函数 ,完全满足多态的第二个条件。编译器会在运行期 根据指针指向的实际对象类型(而非指针的静态类型),选择对应的析构函数版本。

四、代码执行流程分析(加virtual vs 不加virtual

1. 加virtual(正确情况,触发多态析构)

复制代码
int main() {
    // 步骤1:基类指针指向基类对象
    Person* p = new Person;
    delete p; // 调用Person的析构 → 输出~Person()

    // 步骤2:基类指针指向派生类对象
    p = new Student;
    delete p; // 触发多态析构:先调用Student析构,再自动调用Person析构
              // 输出~Student() → ~Person()
    return 0;
}

析构细节

  • 调用Student的析构函数时,会先清理Student的专属资源(代码中无动态资源,仅打印日志);
  • 派生类析构执行完毕后,编译器会自动调用基类的析构函数(遵循 "先子后父" 的析构顺序),清理基类的资源。

2. 不加virtual(错误情况,析构不完整)

复制代码
// 基类析构无virtual
~Person() { cout << "~Person()" << endl; }
// 派生类析构无virtual
~Student() { cout << "~Student()" << endl; }

int main() {
    Person* p = new Person;
    delete p; // 调用Person析构 → 输出~Person()

    p = new Student;
    delete p; // 静态绑定,调用Person析构 → 输出~Person()(Student析构未执行)
    return 0;
}

最终输出两次~Person()Student的析构函数完全被忽略 ------ 若Student中有new的动态资源(如char* _buf = new char[100];),这些资源会永远无法释放,导致内存泄漏。

五、虚析构函数的核心价值

虚析构函数的唯一核心作用是:解决 "基类指针指向派生类对象时,delete 指针无法调用派生类析构函数" 的问题,保证派生类的资源被正确清理,避免内存泄漏。

需要注意的两个细节:

  1. 仅当基类可能被继承,且派生类有动态资源时,才需要将基类析构设为虚函数:如果基类不会被继承,或派生类无动态资源,虚析构的性能开销(虚表指针、动态绑定)可省略;
  2. 析构函数的重写是 "隐式" 的 :无需手动保证函数名一致(编译器已统一处理为destructor),只需给基类析构加virtual,派生类析构自动完成重写。

六、总结

  1. 析构函数的统一命名 :编译器将所有析构函数重命名为destructor,让父子类析构函数具备重写的 "函数名一致" 基础;
  2. 不加 virtual 的问题:析构函数构成隐藏,基类指针指向派生类对象时,仅调用基类析构,派生类资源泄漏;
  3. 加 virtual 的原理:析构函数构成重写,满足多态的两个条件(虚函数重写 + 基类指针调用),触发动态绑定;
  4. delete 的执行逻辑delete p = p->destructor()(虚函数调用) + operator delete(p)(释放内存),前者通过多态调用正确的析构版本,后者释放堆内存;
  5. 析构顺序:多态析构时,先调用派生类析构,再自动调用基类析构,保证资源清理的完整性。

虚析构函数是 C++ 继承体系中处理 "多态对象析构" 的关键语法,是避免派生类资源泄漏的核心手段。


抽象类与final和override关键字

复制代码
// 抽象类Car:包含纯虚函数的类称为抽象类,无法实例化对象
// 作用:定义统一的接口(Drive),强制派生类必须重写该接口,体现"接口继承"的思想
class Car
{
public:
    // 纯虚函数:语法为virtual 函数声明 = 0;
    // 特点:没有函数体,仅作为接口声明;间接强制所有派生类必须重写该函数,否则派生类也会成为抽象类
    // 此处纯虚函数Drive:定义"驾驶"的统一接口,具体实现由派生类(不同车型)自行定义
    virtual void Drive() = 0;
};

// 派生类Benz(奔驰):公有继承自抽象类Car,必须重写纯虚函数Drive,否则Benz也会是抽象类无法实例化
class Benz : public Car
{
public:
    // 重写基类的纯虚函数Drive:实现奔驰车型的驾驶特性
    // virtual可省略(派生类中该函数自动为虚函数),但建议保留以增强可读性
    virtual void Drive()
    {
        cout << "Benz - 舒适\n" << endl;
    }
};

// 派生类BMW(宝马):公有继承自抽象类Car,同样必须重写纯虚函数Drive
class BMW : public Car
{
public:
    // 重写基类的纯虚函数Drive:实现宝马车型的驾驶特性
    virtual void Drive()
    {
        cout << "BMW - 操控\n" << endl;
    }
};

int main()
{
    // 错误示例:抽象类Car无法实例化对象,以下代码会编译报错
    // Car car; // error: cannot declare variable 'car' to be of abstract type 'Car'
    // Car* c = new Car; // error: invalid new-expression of abstract class type 'Car'

    // 正确用法:基类指针指向派生类对象(抽象类的指针/引用可指向其非抽象派生类的对象)
    Car* b = new Benz;
    b->Drive(); // 触发多态,调用Benz::Drive()

    Car* B = new BMW;
    B->Drive(); // 触发多态,调用BMW::Drive()

    // 释放堆内存(若基类有动态资源,需将析构函数也声明为纯虚/虚函数,避免资源泄漏)
    delete b;
    delete B;

    return 0;
}

一、纯虚函数(Pure Virtual Function)

纯虚函数是 C++ 中只声明接口、不提供具体实现的特殊虚函数,是实现 "接口继承" 的核心手段,也是抽象类的判定依据。

1. 纯虚函数的定义语法

复制代码
virtual 返回值类型 函数名(参数列表) = 0;

如代码中Car类的纯虚函数:

复制代码
virtual void Drive() = 0;
  • = 0:并非赋值,而是告诉编译器 "该虚函数无函数体,仅作为接口声明";
  • 纯虚函数可以有函数体 (语法允许在类外定义Car::Drive() { ... }),但这违背纯虚函数的设计初衷,实际开发中几乎不用。

2. 纯虚函数的核心特性

  1. 包含纯虚函数的类是抽象类 :抽象类无法实例化对象 (无论是栈对象还是堆对象),代码中Car car;new Car;都会编译报错 ------ 因为抽象类仅定义接口,未提供完整的实现逻辑。
  2. 抽象类的指针 / 引用可指向非抽象派生类对象 :抽象类虽不能实例化,但它的指针 / 引用是多态的核心载体,可指向其重写了所有纯虚函数的派生类对象 (如代码中Car* b = new Benz;)。
  3. 派生类必须重写所有纯虚函数 :若派生类未重写基类的纯虚函数,该派生类也会成为抽象类,无法实例化。例如若Benz未重写Drive(),则Benz b;会编译报错。

3. 纯虚函数的核心作用

  • 强制接口统一 :抽象类定义了 "必须实现的接口规范",所有派生类都要按该规范重写函数,保证了派生类的接口一致性(如所有车型都必须实现Drive()驾驶逻辑)。
  • 实现接口继承:与 "实现继承"(派生类复用基类的函数实现)不同,纯虚函数仅传递 "接口形式",具体实现由派生类定制,是多态设计中 "开闭原则" 的典型体现。

二、final关键字的两个核心用法

final是 C++11 引入的关键字,用于限制继承和重写,分为 "修饰类" 和 "修饰虚函数" 两种场景。

用法 1:修饰类 ------ 禁止类被继承

若用final修饰一个类,该类将成为最终类,无法被任何其他类继承。

举例 1:修饰Benz类,禁止其被继承

复制代码
// Benz被final修饰,成为最终类,无法被继承
class Benz final : public Car 
{
public:
    virtual void Drive() 
    {
        cout << "Benz - 舒适\n" << endl;
    }
};

// 错误:试图继承final修饰的Benz,编译报错
class BenzAMG : public Benz 
{
public:
    virtual void Drive() 
    {
        cout << "BenzAMG - 性能\n" << endl;
    }
};

编译时会提示error: cannot derive from 'final' base 'Benz' in derived type 'BenzAMG',因为final禁止了对Benz的继承。

用法 2:修饰虚函数 ------ 禁止虚函数被重写

若用final修饰基类的虚函数(包括纯虚函数的重写版本),该虚函数将成为最终虚函数,派生类无法再重写它。

举例 2:修饰CarDrive函数,禁止派生类重写

复制代码
class Car
{
public:
    // Drive被final修饰,派生类无法重写
    virtual void Drive() final = 0; 
};

// 错误:试图重写final修饰的Drive,编译报错
class Benz : public Car 
{
public:
    virtual void Drive() 
    {
        cout << "Benz - 舒适\n" << endl;
    }
};

也可修饰派生类的虚函数,限制更下层的派生类重写:

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

class Benz : public Car 
{
public:
    // Benz的Drive被final修饰,其子类无法重写
    virtual void Drive() final 
    {
        cout << "Benz - 舒适\n" << endl;
    }
};

// 错误:BenzAMG试图重写final的Drive,编译报错
class BenzAMG : public Benz 
{
public:
    virtual void Drive() 
    {
        cout << "BenzAMG - 性能\n" << endl;
    }
};

编译时会提示error: overriding final function 'virtual void Benz::Drive()'

三、override关键字的用法

override是 C++11 引入的关键字,用于显式声明派生类的虚函数是对基类虚函数的重写,编译器会严格检查重写规则,若不满足则直接报错,避免手写错误(如函数名写错、参数不一致等)。

1. override的核心作用

  • 编译期校验重写规则:确保派生类的函数确实重写了基类的虚函数,而非意外定义了同名的新函数(隐藏基类函数)。
  • 增强代码可读性:一眼就能看出该函数是对基类虚函数的重写,无需查看基类定义。

2. 用法举例

正确用法 :在派生类的重写函数后加override,满足重写规则则编译通过。

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

class Benz : public Car 
{
public:
    // 加override,编译器校验重写规则(函数名、参数、返回值一致)
    virtual void Drive() override 
    {
        cout << "Benz - 舒适\n" << endl;
    }
};

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

上述代码符合重写规则,编译正常,且override明确标识了这是重写函数。

错误用法 :若重写规则不满足,override会触发编译报错。

复制代码
class Car
{
public:
    // 基类虚函数带const属性
    virtual void Drive() const = 0;
};

class Benz : public Car 
{
public:
    // 错误1:派生类函数无const,不满足重写规则,override触发编译报错
    virtual void Drive() override 
    {
        cout << "Benz - 舒适\n" << endl;
    }

    // 错误2:函数名写错(Drivee),override触发编译报错
    virtual void Drivee() override 
    {
        cout << "Benz - 舒适\n" << endl;
    }
};

编译时会提示:

  • Drive()error: 'virtual void Benz::Drive()' marked override, but does not override
  • Drivee()error: 'virtual void Benz::Drivee()' marked override, but does not override

这体现了override的校验价值 ------ 避免因手写失误导致 "重写" 变成 "隐藏",从而引发多态失效的问题。

四、总结

  1. 纯虚函数virtual 函数 = 0,定义抽象类的接口规范,强制派生类实现,抽象类无法实例化,其指针 / 引用是多态的核心载体。
  2. final
    • 修饰类:禁止类被继承,成为最终类;
    • 修饰虚函数:禁止虚函数被派生类重写,成为最终虚函数。
  3. override:修饰派生类的虚函数,显式声明重写,编译器校验重写规则,避免手写错误,增强代码可读性。

这三个特性是 C++11 对虚函数和继承体系的重要增强,分别解决了 "接口规范强制""继承 / 重写限制""重写正确性校验" 的问题,是现代 C++ 泛型和多态设计的关键工具。


虚函数表(x86平台下,小端机)

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

	virtual void test() const
	{}

protected:
	int _p = 0;
};

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

private:
	int _s = 0;
};

void func(const Person& rp)
{
	rp.BuyTicket();
}

int main()
{
	Person p;   // 创建基类Person对象
	Student s;  // 创建派生类Student对象

	// 传递基类对象p:func的参数p绑定基类对象,调用Person::BuyTicket
	func(p);
	// 传递派生类对象s:func的参数p(Person&)绑定派生类对象s,调用Student::BuyTicket(多态体现)
	func(s);

	return 0;
}

通过监视查看内存分布情况:

通过地址查看 Person p 对象的内存分布情况:

  • 00 fd 9b 34 对应的是虚函数表的地址
  • 00 00 00 00 对应的是变量 _p

通过地址查看 Person p 对象中 虚函数表 的内存分布情况:

  • 00 fd 12 c1 对应的是 BuyTicket 函数的地址
  • 00 fd 10 cd 对应的是 test 函数的地址

通过地址查看 Student s 对象的内存分布情况:

  • 00 fd 9b 5c 对应的是虚函数表的地址
  • 00 00 00 00 分别对应的是 _p 变量和 _s 变量

通过地址查看 Student s 对象中 虚函数表 的内存分布情况:

  • 00 fd 14 51 对应的是重写后的 BuyTicket 函数的地址
  • 00 fd 10 cd 对应的是 test 函数的地址

接下来结合内存分布,详细解释虚函数表、重写 / 覆盖的底层逻辑,以及多态的实现过程:

一、先明确:虚函数表(vtable)的核心概念

有虚函数的类 中,编译器会为每个类生成一张虚函数表(vtable) ------ 它是一个存储 "类所有虚函数地址" 的数组;同时,该类的每个对象会包含一个虚函数表指针(_vfptr)(对象的第一个成员),指向该类的虚函数表。

虚函数表是 C++ 实现多态的底层核心 :函数调用时,会通过对象的_vfptr找到虚函数表,再根据函数在表中的位置,调用对应的函数地址。

二、x86 小端机的内存存储规则(先理解地址的显示形式)

x86 平台是小端机低字节数据存放在低地址,高字节数据存放在高地址

比如虚函数表地址0x00FD9B34,在内存中会以 "字节逆序" 存储为 34 9B FD 00(低字节34存在低地址,高字节00存在高地址)------ 这是理解内存中地址显示的关键前提。

三、Person 类的内存与虚函数表分析

1. Person 对象(p)的内存分布

Person类有虚函数(BuyTickettest),因此Person对象的内存分为两部分:

  • 第 1 部分:_vfptr(虚函数表指针,占 4 字节,x86 下指针是 4 字节);
  • 第 2 部分:_p(成员变量,占 4 字节)。

对应内存(地址0x00EFFAA0):

  • 前 4 字节:34 9B FD 00 → 对应虚函数表地址0x00FD9B34(小端逆序后的结果);
  • 后 4 字节:00 00 00 00 → 对应_p=0

2. Person 类的虚函数表(地址0x00FD9B34

虚函数表是按虚函数声明的顺序存储函数地址的数组:

  • 第 1 个位置:C1 12 FD 00 → 对应Person::BuyTicket的地址0x00FD12C1
  • 第 2 个位置:CD 10 FD 00 → 对应Person::test的地址0x00FD10CD

四、Student 类的内存与虚函数表分析

StudentPerson的派生类,且重写了BuyTicket------ 派生类的虚函数表规则是:继承基类虚函数表的所有内容,再将 "重写的虚函数地址" 覆盖表中对应位置

1. Student 对象(s)的内存分布

Student对象的内存是 "基类部分 + 派生类新增部分":

  • 第 1 部分:继承Person_vfptr(虚函数表指针,4 字节);
  • 第 2 部分:继承Person_p(4 字节);
  • 第 3 部分:新增的_s(4 字节)。

对应内存(地址0x00EFFA8C):

  • 前 4 字节:5C 9B FD 00 → 对应Student类的虚函数表地址0x00FD9B5C
  • 中间 4 字节:00 00 00 00 → 对应_p=0
  • 后 4 字节:00 00 00 00 → 对应_s=0

2. Student 类的虚函数表(地址0x00FD9B5C

Student的虚函数表是基于Person的虚函数表修改而来:

  • 第 1 个位置:51 14 FD 00 → 对应重写后的Student::BuyTicket 的地址0x00FD1451(覆盖了原Person::BuyTicket的地址);
  • 第 2 个位置:CD 10 FD 00 → 对应Person::test的地址0x00FD10CD(未重写,直接继承基类的函数地址)。

五、为什么 Student 虚函数表中:BuyTicket 地址变了,test 地址没变?

核心原因是 **"重写(语法层)" 对应 "覆盖(底层层)"**:

  • 语法层:Student重写BuyTicket → 底层层:虚函数表中BuyTicket对应的位置,会被Student::BuyTicket的地址覆盖,因此地址改变;
  • 语法层:Student未重写 test → 底层层:虚函数表中test对应的位置,依然沿用基类Person::test的地址,因此地址不变。

六、func 函数中多态的底层执行过程(结合虚函数表)

func的参数是const Person& rp(基类引用),多态的底层逻辑是 "通过rp绑定的对象的_vfptr,找到对应的虚函数表,再调用函数":

场景 1:传递 Person 对象 p → 调用 Person::BuyTicket

func(p)时:

  1. rpp的引用,绑定的是Person对象;
  2. p的内存中取出_vfptr → 指向Person的虚函数表(地址0x00FD9B34);
  3. 在虚函数表中找到第 1 个位置的函数地址 → Person::BuyTicket0x00FD12C1);
  4. 调用该地址对应的函数,输出 "Person 买票 - 全价"。

场景 2:传递 Student 对象 s → 调用 Student::BuyTicket

func(s)时:

  1. rps的引用(基类引用绑定派生类对象,直接指向s的内存);
  2. s的内存中取出_vfptr → 指向Student的虚函数表(地址0x00FD9B5C);
  3. 在虚函数表中找到第 1 个位置的函数地址 → Student::BuyTicket0x00FD1451);
  4. 调用该地址对应的函数,输出 "Student 买票 - 半价"。

七、总结:语法层 "重写" 与底层层 "覆盖" 的关系

  • 语法层叫 "重写(override)":要求派生类函数与基类虚函数的 "函数名、参数、返回值" 完全一致,是 C++ 的语法规则;
  • 底层层叫 "覆盖":重写的本质是 "派生类虚函数表中,对应位置的函数地址被替换为派生类的实现",是多态的底层实现逻辑;
  • 基类指针 / 引用的作用 :保证能 "绑定派生类对象",同时通过对象的_vfptr找到正确的虚函数表 ------ 这是多态的必要条件(若用值传递,会触发切片,丢失派生类的_vfptr)。

虚函数表的存在,让 C++ 能在运行时 "根据对象的实际类型,动态选择函数实现",这就是多态的底层本质。


打印虚函数表

复制代码
// 定义函数指针类型VFUNC:指向「无返回值、无参数」的函数
// 用于表示虚函数表中的函数指针,虚函数表本质是存储虚函数地址的数组
typedef void(*VFUNC)();

// 遍历并打印虚函数表(VFTable)的内容
// 参数a[]:指向虚函数表的指针(数组形式)
// 逻辑:遍历虚函数表,打印每个虚函数的地址并调用该函数,直到遇到空指针(虚表结束标志)
void PrintVFT(VFUNC a[])
{
    // 遍历虚函数表,a[i] == 0表示到达虚表末尾
    for (int i = 0; a[i] != 0; i++)
    {
        // 打印虚表下标、对应虚函数的地址
        printf("a[%d]=%p->", i, a[i]);
        // 取出虚函数表中的函数指针,调用该虚函数
        VFUNC f = a[i];
        f();
    }
    cout << endl; // 换行分隔不同对象的虚表输出
}

// 基类Parent:包含两个虚函数func1、func2
class Parent
{
public:
    // 虚函数func1:Parent的默认实现
    virtual void func1()
    {cout << "Parent::func1()" << endl;}

    // 虚函数func2:Parent的默认实现
    virtual void func2()
    {cout << "Parent::func2()" << endl;}
};

// 派生类Child:公有继承自Parent,重写func1并新增虚函数func3、func4
class Child : public Parent
{
public:
    // 重写(Override)Parent的虚函数func1
    virtual void func1() 
    {cout << "Child::func1()" << endl;}

    // 新增虚函数func3:Child专属的虚函数
    virtual void func3()
    {cout << "Child::func3()" << endl;}

    // 新增虚函数func4:Child专属的虚函数
    virtual void func4()
    {cout << "Child::func4()" << endl;}
};

// 派生类Grandson:公有继承自Child,重写func1
class Grandson : public Child
{
    // 重写Child的虚函数func1(间接重写Parent的func1)
    virtual void func1()
    {cout << "Grandson::func1()" << endl;}
};

int main()
{
    Parent p;    // 创建Parent类对象p
    Child c;     // 创建Child类对象c
    Grandson g;  // 创建Grandson类对象g

    // 【核心:从对象中提取虚函数表指针并传递给PrintVFT】
    // 原理:包含虚函数的对象,其内存布局的第一个成员是「虚表指针(vptr)」,指向虚函数表(VFTable)
    // 步骤拆解:
    // 1. &p:取Parent对象p的地址(指向对象首地址,即虚表指针的地址)
    // 2. (int*)(&p):将对象地址强转为int*,解引用后得到虚表指针的数值(即虚表的首地址)
    // 3. (VFUNC*):将虚表指针的数值强转为VFUNC*(虚函数表的指针类型)
    PrintVFT((VFUNC*)(*(int*)(&p)));
    PrintVFT((VFUNC*)(*(int*)(&c)));
    PrintVFT((VFUNC*)(*(int*)(&g)));

    return 0;
}

一、VS 中虚函数表末尾的空指针(0):遍历的终止依据

C++ 标准并未规定 虚函数表(vtable)的具体实现细节,但Visual Studio 编译器 会在虚函数表的最后一个有效项后添加一个空指针(值为 0) 作为虚表结束的标志。这个设计的核心目的是:

  • 让程序能通过判断a[i] == 0来确定虚表的边界,避免遍历虚表时出现越界访问
  • 不同编译器的实现不同(如 GCC 通常不添加空指针),但 VS 的这个特性让我们可以用a[i] != 0作为遍历终止条件。

简单说:VS 中虚函数表是一个以空指针结尾的函数指针数组 ,因此遍历到a[i] == 0时,就表示已经到了虚表的末尾。

二、代码逐部分深度解析

1. 函数指针类型VFUNC的定义

复制代码
typedef void(*VFUNC)();

这是 C++ 中函数指针的类型别名,含义是:

  • VFUNC代表一种函数指针类型,指向的函数必须满足无返回值(void)、无参数的特征;
  • 示例中所有虚函数(func1/func2/func3/func4)都是void无参,因此用VFUNC可以表示虚函数表中的函数指针,这是操作虚表的基础。

2. 虚函数表遍历函数PrintVFT

复制代码
void PrintVFT(VFUNC a[])
{
    for (int i = 0; a[i] != 0; i++)
    {
        printf("a[%d]=%p->", i, a[i]);
        VFUNC f = a[i];
        f();
    }
    cout << endl;
}

函数的核心逻辑是遍历虚表、打印地址、调用函数

  • 参数a[] :表面是数组,实际是VFUNC*(函数指针的指针),指向虚函数表的首地址;
  • 循环条件a[i] != 0:利用 VS 虚表末尾的空指针作为终止标志,避免越界;
  • printf("a[%d]=%p->", i, a[i]) :打印虚表的下标i和对应位置的虚函数地址(%p是指针的格式化输出);
  • VFUNC f = a[i]; f();:取出虚表中的函数指针,直接调用该虚函数,验证函数的实际实现。

3. 基类Parent的定义

复制代码
class Parent
{
public:
    virtual void func1() {cout << "Parent::func1()" << endl;}
    virtual void func2() {cout << "Parent::func2()" << endl;}
};
  • Parent包含两个虚函数,因此 VS 会为其生成一张虚函数表
  • Parent对象的内存布局(x86 下):第一个成员是虚表指针(vptr,4 字节) ,指向Parent的虚表;
  • Parent的虚表内容(按声明顺序):&Parent::func1&Parent::func2NULL(0)(VS 添加的结束标志)。

4. 派生类Child的定义

复制代码
class Child : public Parent
{
public:
    virtual void func1() {cout << "Child::func1()" << endl;}
    virtual void func3() {cout << "Child::func3()" << endl;}
    virtual void func4() {cout << "Child::func4()" << endl;}
};

ChildParent的公有派生类,其虚表遵循 **"继承 + 覆盖 + 追加"** 规则:

  1. 继承 :先继承Parent虚表的所有项;
  2. 覆盖 :重写的func1会将虚表中&Parent::func1的位置替换为&Child::func1
  3. 追加 :派生类新增的虚函数(func3func4)按声明顺序追加到虚表的末尾
  4. 结束标志 :VS 在最后添加NULL(0)

因此Child的虚表内容:&Child::func1&Parent::func2&Child::func3&Child::func4NULL(0)

5. 派生类Grandson的定义

复制代码
class Grandson : public Child
{
    virtual void func1() {cout << "Grandson::func1()" << endl;}
};

GrandsonChild的派生类,仅重写了func1,其虚表规则是:

  • 继承Child的虚表结构;
  • 将虚表中&Child::func1的位置替换为&Grandson::func1
  • 其他项(func2/func3/func4)保持不变,末尾仍为NULL(0)

因此Grandson的虚表内容:&Grandson::func1&Parent::func2&Child::func3&Child::func4NULL(0)

6. main函数:提取虚表指针并遍历

复制代码
int main()
{
    Parent p;    // Parent对象:内存首地址是Parent的虚表指针
    Child c;     // Child对象:内存首地址是Child的虚表指针
    Grandson g;  // Grandson对象:内存首地址是Grandson的虚表指针

    // 提取并遍历Parent的虚表
    PrintVFT((VFUNC*)(*(int*)(&p)));
    // 提取并遍历Child的虚表
    PrintVFT((VFUNC*)(*(int*)(&c)));
    // 提取并遍历Grandson的虚表
    PrintVFT((VFUNC*)(*(int*)(&g)));

    return 0;
}

核心难点:虚表指针的提取逻辑(x86 平台,小端机,指针占 4 字节):

  1. &p:取Parent对象p首地址 ,该地址指向对象的第一个成员 ------虚表指针(vptr
  2. (int*)(&p):将对象首地址强转为int*(因为 x86 下指针占 4 字节,与int长度一致),此时(int*)(&p)是 "虚表指针的地址";
  3. *(int*)(&p):解引用得到虚表指针的数值(即虚函数表的首地址);
  4. (VFUNC*)(*(int*)(&p)):将虚表的首地址强转为VFUNC*(虚函数表的指针类型),传递给PrintVFT进行遍历。

注意:若在 x64 平台下,指针占 8 字节,需将int*替换为long long*,否则会因类型长度不匹配导致提取虚表地址错误。

三、代码的输出结果与底层逻辑

复制代码
a[0]=00321159->Parent::func1()
a[1]=0032105F->Parent::func2()

a[0]=0032132A->Child::func1()
a[1]=0032105F->Parent::func2()
a[2]=0032106E->Child::func3()
a[3]=003210A0->Child::func4()

a[0]=0032122B->Grandson::func1()
a[1]=0032105F->Parent::func2()
a[2]=0032106E->Child::func3()
a[3]=003210A0->Child::func4()

输出结果验证了虚表的规则:

  1. Parent的虚表只有两个项(func1/func2),遍历到第 2 项后遇到0终止;
  2. Child的虚表是 "覆盖func1+ 追加func3/func4",共 4 个项;
  3. Grandson仅覆盖func1,其他项与Child一致。

四、总结

  1. VS 虚表的结束标志 :编译器在虚表末尾添加空指针(0),因此可用a[i] != 0遍历,这是 VS 的专属实现细节;
  2. 虚表的核心规则:派生类虚表 = 继承基类虚表 + 覆盖重写的虚函数地址 + 追加新增的虚函数地址 + 空指针结束;
  3. 虚表指针的提取:利用 "有虚函数的对象首成员是虚表指针" 的内存布局,通过指针强转和解引用,从对象中提取虚表首地址;
  4. 函数指针的作用VFUNC类型匹配虚函数的签名,让我们能直接调用虚表中的函数指针,验证虚函数的实际实现。

这段代码的本质是手动操作虚函数表,从底层视角验证了 C++ 多态的实现逻辑 ------ 虚函数的重写对应虚表地址的覆盖,虚表指针则决定了运行时调用的函数版本。

相关推荐
WBluuue7 分钟前
数据结构与算法:dp优化——优化尝试和状态设计
c++·算法·leetcode·动态规划
睡不醒的kun1 小时前
定长滑动窗口-基础篇(2)
数据结构·c++·算法·leetcode·职场和发展·滑动窗口·定长滑动窗口
小王努力学编程1 小时前
LangChain——AI应用开发框架(核心组件1)
linux·服务器·前端·数据库·c++·人工智能·langchain
txzrxz1 小时前
单调栈详解(含题目)
数据结构·c++·算法·前缀和·单调栈
Trouvaille ~2 小时前
【Linux】进程间通信(二):命名管道与进程池架构实战
linux·c++·chrome·架构·进程间通信·命名管道·进程池
HellowAmy2 小时前
我的C++规范 - 随机时间点
开发语言·c++·代码规范
啊阿狸不会拉杆3 小时前
《计算机操作系统》第七章 - 文件管理
开发语言·c++·算法·计算机组成原理·os·计算机操作系统
lixinnnn.3 小时前
字符串拼接:Cities and States S
开发语言·c++·算法
csdn_aspnet3 小时前
C++常用算法深度解析:从STL到现代C++的算法艺术
c++
kk”4 小时前
C++智能指针
开发语言·c++