多态的条件以及示例代码
// 基类 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;
p = Student;
p;
;
}
一、析构函数的特殊处理:编译器统一重命名为 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* b = Benz;
b->();
Car* B = BMW;
B->();
b;
B;
;
}
一、纯虚函数(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
{
cout << << endl;
}
{
cout << << endl;
}
{
cout << << endl;
}
};
: Child {
{
cout << << endl;
}
};
{
Parent p;
Child c;
Grandson g;
((VFUNC*)(*(*)(&p)));
((VFUNC*)(*(*)(&c)));
((VFUNC*)(*(*)(&g)));
;
}
一、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]=>::()
输出结果验证了虚表的规则:
Parent的虚表只有两个项(func1/func2),遍历到第 2 项后遇到0终止;Child的虚表是 '覆盖func1+ 追加func3/func4',共 4 个项;Grandson仅覆盖func1,其他项与Child一致。
四、总结
- VS 虚表的结束标志:编译器在虚表末尾添加空指针(0),因此可用
a[i] != 0遍历,这是 VS 的专属实现细节; - 虚表的核心规则:派生类虚表 = 继承基类虚表 + 覆盖重写的虚函数地址 + 追加新增的虚函数地址 + 空指针结束;
- 虚表指针的提取:利用 '有虚函数的对象首成员是虚表指针' 的内存布局,通过指针强转和解引用,从对象中提取虚表首地址;
- 函数指针的作用:
VFUNC类型匹配虚函数的签名,让我们能直接调用虚表中的函数指针,验证虚函数的实际实现。
这段代码的本质是手动操作虚函数表,从底层视角验证了 C++ 多态的实现逻辑 —— 虚函数的重写对应虚表地址的覆盖,虚表指针则决定了运行时调用的函数版本。

