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

目录

多态的条件以及示例代码

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

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

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

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

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

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

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

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

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

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

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

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

一、纯虚函数(Pure Virtual Function)

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

三、override关键字的用法

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

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

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

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

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

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

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

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

一、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::func2 → NULL(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::func4 → NULL(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::func4 → NULL(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++ 多态的实现逻辑 —— 虚函数的重写对应虚表地址的覆盖,虚表指针则决定了运行时调用的函数版本。

Read more

【Java 开发日记】我们来说一说 ThreadLocal 内存泄漏

【Java 开发日记】我们来说一说 ThreadLocal 内存泄漏

目录 ThreadLocal 解决什么问题 ThreadLocal 为什么会内存泄漏 ThreadLocal 是基于 ThreadLocalMap 实现的 源码分析 ThreadLocal.set() replaceStaleEntry expungeStaleEntry ThreadLocal.get() ThreadLocal 解决什么问题 ThreadLocal是为了解决对象不能被多线程共享访问的问题,通过 threadLocal.set() 方法将对象实例保存在每个线程自己所拥有的 threadLocalMap 中,这样的话每个线程都使用自己的对象实例,彼此不会影响从而达到了隔离的作用,这样就解决了对象在被共享访问时带来的线程安全问题 先把 Thread, ThreadLocal, ThreadLocalMap 的关系捋一捋: 可以看到,在 Thread 中持有一个 ThreadLocalMap , ThreadLocalMap 又是由 Entry 来组成的,在 Entry 里面有 ThreadLocal 和 value  ThreadLocal 为什

By Ne0inhk
Java 大视界 -- 实战|Java + Elasticsearch 电商搜索系统:分词优化与千万级 QPS 性能调优(439)

Java 大视界 -- 实战|Java + Elasticsearch 电商搜索系统:分词优化与千万级 QPS 性能调优(439)

Java 大视界 -- 实战|Java + Elasticsearch 电商搜索系统:分词优化与千万级 QPS 性能调优(439) * 引言: * 正文: * 一、 项目概述与技术选型 * 1.1 项目核心价值 * 1.2 核心技术选型(基于官方稳定版本,无兼容性风险) * 1.2.1 技术栈明细(附官方出处) * 1.2.2 选型核心原则(实战验证,规避坑点) * 1.3 系统核心架构 * 1.3.1 架构分层说明 * 二、 核心实体设计与环境准备 * 2.1 核心实体设计(贴合母婴业务,字段精准选型) * 2.1.

By Ne0inhk

3步搞定jsPDF中文显示:从乱码到完美输出的完整指南

3步搞定 jsPDF 中文显示:从乱码到完美输出的完整指南 jsPDF 默认只支持 14 种标准 PDF 字体(Helvetica、Times 等),完全不支持中文字符,导致中文显示为方框或乱码。 核心解决办法:引入支持中文的自定义字体(TTF → 转换 → 加载)。 2025-2026 年最推荐、最稳定的方式是使用思源黑体 / 思源宋体 / Noto Sans CJK 等免费开源字体,并通过官方推荐的转换工具处理。 步骤 1:准备中文字体文件(.ttf) 选择体积适中、支持简体中文的字体(推荐以下任一): * 思源黑体(Source Han Sans):现代感强,推荐 * 下载地址:https://github.com/adobe-fonts/source-han-sans (选择 OTC

By Ne0inhk
Java初中级工程师面试指南:从理论到实战的完美回答

Java初中级工程师面试指南:从理论到实战的完美回答

个人名片 🎓作者简介:java领域优质创作者 🌐个人主页:码农阿豪 📞工作室:新空间代码工作室(提供各种软件服务) 💌个人邮箱:[[email protected]] 📱个人微信:15279484656 🌐个人导航网站:www.forff.top 💡座右铭:总有人要赢。为什么不能是我呢? * 专栏导航: 码农阿豪系列专栏导航 面试专栏:收集了java相关高频面试题,面试实战总结🍻🎉🖥️ Spring5系列专栏:整理了Spring5重要知识点与实战演练,有案例可直接使用🚀🔧💻 Redis专栏:Redis从零到一学习分享,经验总结,案例实战💐📝💡 全栈系列专栏:海纳百川有容乃大,可能你想要的东西里面都有🤸🌱🚀 目录 * Java初中级工程师面试指南:从理论到实战的完美回答 * 引言 * 一、Java基础 * 1. Java集合框架:ArrayList vs LinkedList * 2. 多线程:Thread

By Ne0inhk