目录
[二、虚函数:实现动态多态的 "开关"](#二、虚函数:实现动态多态的 “开关”)
[三、虚函数重写(Override):多态的 "前提基础"](#三、虚函数重写(Override):多态的 “前提基础”)
一、析构函数的特殊处理:编译器统一重命名为destructor
二、不加virtual:父子类析构函数构成隐藏关系,导致析构不完整
三、加virtual:父子类析构函数构成重写,满足多态的两个条件
[四、代码执行流程分析(加virtual vs 不加virtual)](#四、代码执行流程分析(加virtual vs 不加virtual))
[一、纯虚函数(Pure Virtual Function)](#一、纯虚函数(Pure Virtual Function))
[二、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(不是虚函数),即使派生类定义了同名函数,也只是隐藏而非重写,函数调用会被静态绑定(编译期确定); - 若派生类未重写基类虚函数,调用时会默认执行基类的虚函数版本,无法体现多态。
代码中:
- 基类
Person的BuyTicket是虚函数; - 派生类
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;
}
-
调用
func(p):func的参数const Person& p绑定基类对象p;- 运行时,通过
p的虚表指针找到Person的虚函数表,执行Person::BuyTicket(),打印Person买票 - 全价。
-
调用
func(s):func的参数const Person& p绑定派生类对象s(基类引用指向派生类对象);- 运行时,通过
s的虚表指针找到Student的虚函数表,执行Student::BuyTicket(),打印Student买票 - 半价。
整个过程中,p.BuyTicket()的调用版本不是由编译期的Person&类型决定 ,而是由运行期绑定的实际对象类型 (Person/Student)决定,这就是动态多态的核心。
六、总结
- 多态的本质:动态多态是 "运行期根据实际对象类型,选择虚函数版本" 的机制,体现 "同一行为,不同对象的不同表现";
- 虚函数的作用 :是实现动态多态的核心开关,用
virtual修饰后,函数调用从编译期静态绑定延迟到运行期动态绑定; - 虚函数重写:派生类必须严格遵循 "三同" 规则重写基类虚函数,这是多态的前提;
- 多态的两个必要条件 :
- 基类虚函数被派生类重写;
- 通过基类的指针 / 引用调用虚函数(值传递会触发切片,多态失效)。
虚函数的析构函数
// 基类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 p(p指向Student对象)时,只会调用Person的析构函数,输出两次~Person(),而Student的析构函数从未执行 ------ 这就是隐藏关系导致的析构不完整。
三、加virtual:父子类析构函数构成重写,满足多态的两个条件
给基类析构函数加virtual后,析构函数成为虚函数 ,此时父子类的析构函数(底层destructor)会构成重写,并满足动态多态的两个必要条件,最终实现 "基类指针指向派生类对象时,正确调用派生类析构"。
条件 1:基类定义虚函数,派生类重写该虚函数
- 基类
Person的析构函数被virtual修饰,成为虚函数; - 派生类
Student的析构函数因编译器统一重命名为destructor,与基类虚函数的函数名、参数列表(析构无参数)、返回值(析构无返回值) 完全一致,满足重写规则(C++ 中析构函数的重写是特殊的,无需手动匹配参数 / 返回值); - 派生类的析构函数会自动继承基类的
virtual属性(即使省略virtual关键字,依然是虚函数)。
条件 2:通过基类的指针 / 引用调用虚函数
delete基类指针的操作包含两个核心步骤:
- 调用指针指向对象的
destructor函数(即p->destructor()); - 调用
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 指针无法调用派生类析构函数" 的问题,保证派生类的资源被正确清理,避免内存泄漏。
需要注意的两个细节:
- 仅当基类可能被继承,且派生类有动态资源时,才需要将基类析构设为虚函数:如果基类不会被继承,或派生类无动态资源,虚析构的性能开销(虚表指针、动态绑定)可省略;
- 析构函数的重写是 "隐式" 的 :无需手动保证函数名一致(编译器已统一处理为
destructor),只需给基类析构加virtual,派生类析构自动完成重写。
六、总结
- 析构函数的统一命名 :编译器将所有析构函数重命名为
destructor,让父子类析构函数具备重写的 "函数名一致" 基础; - 不加 virtual 的问题:析构函数构成隐藏,基类指针指向派生类对象时,仅调用基类析构,派生类资源泄漏;
- 加 virtual 的原理:析构函数构成重写,满足多态的两个条件(虚函数重写 + 基类指针调用),触发动态绑定;
- delete 的执行逻辑 :
delete p=p->destructor()(虚函数调用) +operator delete(p)(释放内存),前者通过多态调用正确的析构版本,后者释放堆内存; - 析构顺序:多态析构时,先调用派生类析构,再自动调用基类析构,保证资源清理的完整性。
虚析构函数是 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. 纯虚函数的核心特性
- 包含纯虚函数的类是抽象类 :抽象类无法实例化对象 (无论是栈对象还是堆对象),代码中
Car car;或new Car;都会编译报错 ------ 因为抽象类仅定义接口,未提供完整的实现逻辑。 - 抽象类的指针 / 引用可指向非抽象派生类对象 :抽象类虽不能实例化,但它的指针 / 引用是多态的核心载体,可指向其重写了所有纯虚函数的派生类对象 (如代码中
Car* b = new Benz;)。 - 派生类必须重写所有纯虚函数 :若派生类未重写基类的纯虚函数,该派生类也会成为抽象类,无法实例化。例如若
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:修饰Car的Drive函数,禁止派生类重写
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的校验价值 ------ 避免因手写失误导致 "重写" 变成 "隐藏",从而引发多态失效的问题。
四、总结
- 纯虚函数 :
virtual 函数 = 0,定义抽象类的接口规范,强制派生类实现,抽象类无法实例化,其指针 / 引用是多态的核心载体。 final:- 修饰类:禁止类被继承,成为最终类;
- 修饰虚函数:禁止虚函数被派生类重写,成为最终虚函数。
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类有虚函数(BuyTicket、test),因此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 类的内存与虚函数表分析
Student是Person的派生类,且重写了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)时:
rp是p的引用,绑定的是Person对象;- 从
p的内存中取出_vfptr→ 指向Person的虚函数表(地址0x00FD9B34); - 在虚函数表中找到第 1 个位置的函数地址 →
Person::BuyTicket(0x00FD12C1); - 调用该地址对应的函数,输出 "Person 买票 - 全价"。
场景 2:传递 Student 对象 s → 调用 Student::BuyTicket
当func(s)时:
rp是s的引用(基类引用绑定派生类对象,直接指向s的内存);- 从
s的内存中取出_vfptr→ 指向Student的虚函数表(地址0x00FD9B5C); - 在虚函数表中找到第 1 个位置的函数地址 →
Student::BuyTicket(0x00FD1451); - 调用该地址对应的函数,输出 "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;}
};
Child是Parent的公有派生类,其虚表遵循 **"继承 + 覆盖 + 追加"** 规则:
- 继承 :先继承
Parent虚表的所有项; - 覆盖 :重写的
func1会将虚表中&Parent::func1的位置替换为&Child::func1; - 追加 :派生类新增的虚函数(
func3、func4)按声明顺序追加到虚表的末尾; - 结束标志 :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;}
};
Grandson是Child的派生类,仅重写了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 字节):
&p:取Parent对象p的首地址 ,该地址指向对象的第一个成员 ------虚表指针(vptr);(int*)(&p):将对象首地址强转为int*(因为 x86 下指针占 4 字节,与int长度一致),此时(int*)(&p)是 "虚表指针的地址";*(int*)(&p):解引用得到虚表指针的数值(即虚函数表的首地址);(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()
输出结果验证了虚表的规则:
Parent的虚表只有两个项(func1/func2),遍历到第 2 项后遇到0终止;Child的虚表是 "覆盖func1+ 追加func3/func4",共 4 个项;Grandson仅覆盖func1,其他项与Child一致。
四、总结
- VS 虚表的结束标志 :编译器在虚表末尾添加空指针(0),因此可用
a[i] != 0遍历,这是 VS 的专属实现细节; - 虚表的核心规则:派生类虚表 = 继承基类虚表 + 覆盖重写的虚函数地址 + 追加新增的虚函数地址 + 空指针结束;
- 虚表指针的提取:利用 "有虚函数的对象首成员是虚表指针" 的内存布局,通过指针强转和解引用,从对象中提取虚表首地址;
- 函数指针的作用 :
VFUNC类型匹配虚函数的签名,让我们能直接调用虚表中的函数指针,验证虚函数的实际实现。
这段代码的本质是手动操作虚函数表,从底层视角验证了 C++ 多态的实现逻辑 ------ 虚函数的重写对应虚表地址的覆盖,虚表指针则决定了运行时调用的函数版本。