跳到主要内容 C++ 多态深度解析:从语法到虚函数表底层实现 | 极客日志
C++
C++ 多态深度解析:从语法到虚函数表底层实现 深入解析 C++ 多态机制,涵盖静态与动态多态区别、虚函数定义与重写规则、抽象类及 final/override 关键字用法。重点剖析虚函数表(vtable)在 x86 小端机下的内存布局,通过代码示例演示基类指针指向派生类对象时的动态绑定过程,以及虚析构函数对防止内存泄漏的关键作用。结合 VS 调试环境展示虚表遍历方法,阐明语法层“重写”与底层层“覆盖”的对应关系,帮助读者建立完整的 C++ 多态知识体系。
JavaCoder 发布于 2026/3/27 更新于 2026/4/18 4 浏览多态的条件以及示例代码
class Person {
public :
{
cout << << endl;
}
};
: Person {
:
{
cout << << endl;
}
};
{
p. ();
}
{
Person p;
Student s;
(p);
(s);
;
}
virtual void BuyTicket () const
"Person 买票 - 全价"
class
Student
public
public
virtual void BuyTicket () const
"Student 买票 - 半价"
void func (const Person& p)
BuyTicket
int main ()
func
func
return
0
一、多态的核心定义:同一行为,不同表现 多态是 C++ 面向对象三大特性(封装、继承、多态)的核心,指同一操作作用于不同的对象,会产生不同的执行结果 。C++ 中的多态分为两类:
静态多态(编译期多态) :由函数重载、模板实现,函数调用的版本在编译期 就确定(如 Add(int, int)和Add(double, double)的重载);
动态多态(运行期多态) :由虚函数 + 继承 实现,函数调用的版本在运行期 才确定(代码中核心体现的类型)。
代码中的多态表现:调用 func(p)和func(s)时,传入的是不同对象(Person/Student),p.BuyTicket()最终执行了不同的函数版本(基类 / 派生类),这就是动态多态的核心 ——'调用的函数版本由运行时绑定的对象类型决定,而非编译时的参数类型' 。
二、虚函数:实现动态多态的 '开关' 虚函数是指用 virtual关键字修饰的类成员函数 ,其核心作用是打破编译期的静态绑定,让函数调用的版本延迟到运行期确定 。
virtual void BuyTicket () const { ... }
virtual仅需在基类声明虚函数时添加 ,派生类重写该函数时,virtual可省略(编译器会自动将派生类的重写函数视为虚函数),但建议保留以增强代码可读性;
虚函数的本质是告诉编译器:'不要在编译期确定该函数的调用版本,留到运行期根据实际对象类型再决定'。
虚函数必须是类的非静态成员函数 (静态成员函数属于类,而非对象,无法绑定到具体对象);
虚函数可被派生类重写 (Override),这是实现多态的基础;
若基类的虚函数是 const成员函数(如代码中BuyTicket() const),派生类重写时也必须保持const属性(属于重写规则的一部分)。
三、虚函数重写(Override):多态的 '前提基础' 虚函数重写是指派生类中定义了与基类虚函数 '原型完全一致' 的函数 ,是实现动态多态的必要前提 (无重写则无多态)。
函数名相同 :如基类是 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。
这是实现动态多态的核心语法约束 ,也是最容易被忽视的点。若直接用基类对象 (值传递)调用虚函数,会触发切片 ,无法实现多态。
基类的指针 / 引用不会拷贝派生类对象 ,而是直接指向 / 绑定派生类对象的内存 ,运行时能通过对象的虚函数表指针 找到实际对象的虚函数版本。
代码中 func的参数是const Person& p(基类引用):
当传入 Person p时,p绑定基类对象,调用基类的BuyTicket;
当传入 Student s时,p绑定派生类对象的基类部分(无拷贝,仅引用),运行时能识别出实际对象是 Student,调用派生类的 BuyTicket。
若将 func的参数改为值传递 (const Person p),传入 Student s时会发生切片 :编译器将s中的基类部分拷贝到 p中,p成为一个纯粹的 Person对象(丢失派生类的所有信息)。此时调用 p.BuyTicket(),无论传入的是 Person还是 Student,都会执行基类的虚函数版本,多态失效。
void func (const Person p) {
p.BuyTicket ();
}
五、代码中的多态执行流程分析 int main () {
Person p;
Student s;
func (p);
func (s);
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修饰后,函数调用从编译期静态绑定延迟到运行期动态绑定;
虚函数重写 :派生类必须严格遵循 '三同' 规则重写基类虚函数,这是多态的前提;
多态的两个必要条件 :
基类虚函数被派生类重写;
通过基类的指针 / 引用调用虚函数(值传递会触发切片,多态失效)。
虚函数的析构函数
class Person {
public :
virtual void BuyTicket () const {
cout << "Person 买票 - 全价" << endl;
}
virtual ~Person () {
cout << "~Person()" << endl;
}
};
class Student : public Person {
public :
virtual void BuyTicket () const {
cout << "Student 买票 - 半价" << endl;
}
virtual ~Student () {
cout << "~Student()" << endl;
}
};
int main () {
Person* p = new Person;
delete p;
p = new Student;
delete p;
return 0 ;
}
一、析构函数的特殊处理:编译器统一重命名为 destructor C++ 中析构函数的语法名是 ~类名()(如 ~Person()、~Student()),但编译器会将所有类的析构函数统一处理为内部名称为 destructor的函数 —— 这是析构函数的一个关键特性,也是父子类析构函数能构成 '重写' 的前提。
普通函数的重写要求函数名完全一致 ,而析构函数的语法名随类名变化(~Person≠~Student),编译器通过统一重命名为 destructor,让父子类的析构函数具备了 '函数名相同' 的重写基础;
这个处理是编译器的底层行为,对程序员透明,但直接决定了析构函数的重写 / 隐藏规则。
简单说:无论写的是 ~Person()还是~Student(),编译器眼里它们都是名为 destructor的函数,这是析构函数与普通虚函数的核心差异点。
二、不加 virtual:父子类析构函数构成隐藏关系 ,导致析构不完整 如果基类的析构函数不加 virtual,父子类的析构函数(底层都是 destructor)会触发隐藏规则 (派生类的同名函数隐藏基类的同名函数,作用域不同),而非重写。此时会引发严重问题:
隐藏的规则是:不同作用域(基类 / 派生类)的同名函数,无论参数是否一致,派生类函数都会隐藏基类函数 。由于析构函数被统一重命名为 destructor,派生类的 destructor会隐藏基类的 destructor,编译器会按静态绑定 (编译期确定调用版本)处理析构函数的调用。
当用基类指针指向派生类对象 并 delete时,编译器会根据指针的静态类型(基类) 调用基类的析构函数,而派生类的析构函数完全被忽略。如果派生类中有动态分配的资源(如 new的数组、指针),这些资源会因派生类析构未执行而泄漏。
~Person () { cout << "~Person()" << endl; }
~Student () { cout << "~Student()" << endl; }
main函数中执行 delete p(p指向 Student对象)时,只会调用 Person的析构函数,输出两次 ~Person(),而 Student的析构函数从未执行 —— 这就是隐藏关系导致的析构不完整。
三、加 virtual:父子类析构函数构成重写 ,满足多态的两个条件 给基类析构函数加 virtual后,析构函数成为虚函数 ,此时父子类的析构函数(底层 destructor)会构成重写 ,并满足动态多态的两个必要条件,最终实现 '基类指针指向派生类对象时,正确调用派生类析构'。
基类 Person的析构函数被 virtual修饰,成为虚函数;
派生类 Student的析构函数因编译器统一重命名为 destructor,与基类虚函数的函数名、参数列表(析构无参数)、返回值(析构无返回值) 完全一致,满足重写规则 (C++ 中析构函数的重写是特殊的,无需手动匹配参数 / 返回值);
派生类的析构函数会自动继承基类的 virtual属性(即使省略 virtual关键字,依然是虚函数)。
调用指针指向对象的 destructor函数(即 p->destructor());
调用 operator delete(p)释放堆内存。
其中第一步 p->destructor() 正是通过基类指针调用虚函数 ,完全满足多态的第二个条件。编译器会在运行期 根据指针指向的实际对象类型 (而非指针的静态类型),选择对应的析构函数版本。
四、代码执行流程分析(加 virtual vs 不加 virtual) 1. 加 virtual(正确情况,触发多态析构)
int main () {
Person* p = new Person;
delete p;
p = new Student;
delete p;
return 0 ;
}
调用 Student的析构函数时,会先清理 Student的专属资源(代码中无动态资源,仅打印日志);
派生类析构执行完毕后,编译器会自动调用基类的析构函数 (遵循 '先子后父' 的析构顺序),清理基类的资源。
2. 不加 virtual(错误情况,析构不完整)
~Person () { cout << "~Person()" << endl; }
~Student () { cout << "~Student()" << endl; }
int main () {
Person* p = new Person;
delete p;
p = new Student;
delete p;
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 关键字
class Car {
public :
virtual void Drive () = 0 ;
};
class Benz : public Car {
public :
virtual void Drive () {
cout << "Benz - 舒适\n" << endl;
}
};
class BMW : public Car {
public :
virtual void Drive () {
cout << "BMW - 操控\n" << endl;
}
};
int main () {
Car* b = new Benz;
b->Drive ();
Car* B = new BMW;
B->Drive ();
delete b;
delete B;
return 0 ;
}
一、纯虚函数(Pure Virtual Function) 纯虚函数是 C++ 中只声明接口、不提供具体实现 的特殊虚函数,是实现 '接口继承' 的核心手段,也是抽象类的判定依据。
virtual 返回值类型 函数名 (参数列表) = 0 ;
virtual void Drive () = 0 ;
= 0:并非赋值,而是告诉编译器 '该虚函数无函数体,仅作为接口声明';
纯虚函数可以有函数体 (语法允许在类外定义 Car::Drive() { ... }),但这违背纯虚函数的设计初衷,实际开发中几乎不用。
包含纯虚函数的类是抽象类 :抽象类无法实例化对象 (无论是栈对象还是堆对象),代码中 Car car;或new Car;都会编译报错 —— 因为抽象类仅定义接口,未提供完整的实现逻辑。
抽象类的指针 / 引用可指向非抽象派生类对象 :抽象类虽不能实例化,但它的指针 / 引用是多态的核心载体,可指向其重写了所有纯虚函数的派生类对象 (如代码中 Car* b = new Benz;)。
派生类必须重写所有纯虚函数 :若派生类未重写基类的纯虚函数,该派生类也会成为抽象类,无法实例化。例如若 Benz未重写Drive(),则Benz b;会编译报错。
强制接口统一 :抽象类定义了 '必须实现的接口规范',所有派生类都要按该规范重写函数,保证了派生类的接口一致性(如所有车型都必须实现 Drive()驾驶逻辑)。
实现接口继承 :与 '实现继承'(派生类复用基类的函数实现)不同,纯虚函数仅传递 '接口形式',具体实现由派生类定制,是多态设计中 '开闭原则' 的典型体现。
二、final关键字的两个核心用法 final是 C++11 引入的关键字,用于限制继承和重写 ,分为 '修饰类' 和 '修饰虚函数' 两种场景。
若用 final修饰一个类,该类将成为最终类 ,无法被任何其他类继承。
class Benz final : public Car {
public :
virtual void Drive () {
cout << "Benz - 舒适\n" << endl;
}
};
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的继承。
若用 final修饰基类的虚函数(包括纯虚函数的重写版本),该虚函数将成为最终虚函数 ,派生类无法再重写它。
举例 2:修饰 Car的Drive函数,禁止派生类重写
class Car {
public :
virtual void Drive () final = 0 ;
};
class Benz : public Car {
public :
virtual void Drive () {
cout << "Benz - 舒适\n" << endl;
}
};
class Car {
public :
virtual void Drive () = 0 ;
};
class Benz : public Car {
public :
virtual void Drive () final {
cout << "Benz - 舒适\n" << endl;
}
};
class BenzAMG : public Benz {
public :
virtual void Drive () {
cout << "BenzAMG - 性能\n" << endl;
}
};
编译时会提示 error: overriding final function 'virtual void Benz::Drive()'。
三、override关键字的用法 override是 C++11 引入的关键字,用于显式声明派生类的虚函数是对基类虚函数的重写 ,编译器会严格检查重写规则,若不满足则直接报错,避免手写错误(如函数名写错、参数不一致等)。
编译期校验重写规则 :确保派生类的函数确实重写了基类的虚函数,而非意外定义了同名的新函数(隐藏基类函数)。
增强代码可读性 :一眼就能看出该函数是对基类虚函数的重写,无需查看基类定义。
正确用法 :在派生类的重写函数后加 override,满足重写规则则编译通过。
class Car {
public :
virtual void Drive () = 0 ;
};
class Benz : public Car {
public :
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 :
virtual void Drive () const = 0 ;
};
class Benz : public Car {
public :
virtual void Drive () override {
cout << "Benz - 舒适\n" << endl;
}
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;
Student s;
func (p);
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 类的内存与虚函数表分析 Person类有虚函数(BuyTicket、test),因此 Person对象的内存分为两部分:
第 1 部分:_vfptr(虚函数表指针,占 4 字节,x86 下指针是 4 字节);
第 2 部分:_p(成员变量,占 4 字节)。
前 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—— 派生类的虚函数表规则是:继承基类虚函数表的所有内容,再将 '重写的虚函数地址' 覆盖表中对应位置 。
Student对象的内存是 '基类部分 + 派生类新增部分':
第 1 部分:继承 Person的_vfptr(虚函数表指针,4 字节);
第 2 部分:继承 Person的_p(4 字节);
第 3 部分:新增的 _s(4 字节)。
前 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
rp是 p的引用,绑定的是 Person对象;
从 p的内存中取出 _vfptr → 指向 Person的虚函数表(地址 0x00FD9B34);
在虚函数表中找到第 1 个位置的函数地址 → Person::BuyTicket(0x00FD12C1);
调用该地址对应的函数,输出 'Person 买票 - 全价'。
场景 2:传递 Student 对象 s → 调用 Student::BuyTicket
rp是 s的引用(基类引用绑定派生类对象,直接指向 s的内存);
从 s的内存中取出 _vfptr → 指向 Student的虚函数表(地址 0x00FD9B5C);
在虚函数表中找到第 1 个位置的函数地址 → Student::BuyTicket(0x00FD1451);
调用该地址对应的函数,输出 'Student 买票 - 半价'。
七、总结:语法层 '重写' 与底层层 '覆盖' 的关系
语法层叫 '重写(override)' :要求派生类函数与基类虚函数的 '函数名、参数、返回值' 完全一致,是 C++ 的语法规则;
底层层叫 '覆盖' :重写的本质是 '派生类虚函数表中,对应位置的函数地址被替换为派生类的实现',是多态的底层实现逻辑;
基类指针 / 引用的作用 :保证能 '绑定派生类对象',同时通过对象的_vfptr找到正确的虚函数表 —— 这是多态的必要条件(若用值传递,会触发切片,丢失派生类的_vfptr)。
虚函数表的存在,让 C++ 能在运行时 '根据对象的实际类型,动态选择函数实现',这就是多态的底层本质。
打印虚函数表
typedef void (*VFUNC) () ;
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;
}
class Parent {
public :
virtual void func1 () {
cout << "Parent::func1()" << endl;
}
virtual void func2 () {
cout << "Parent::func2()" << endl;
}
};
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;
}
};
class Grandson : public Child {
virtual void func1 () {
cout << "Grandson::func1()" << endl;
}
};
int main () {
Parent p;
Child c;
Grandson g;
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时,就表示已经到了虚表的末尾。
二、代码逐部分深度解析
VFUNC代表一种函数指针类型,指向的函数必须满足无返回值(void)、无参数 的特征;
示例中所有虚函数(func1/func2/func3/func4)都是 void无参,因此用 VFUNC可以表示虚函数表中的函数指针,这是操作虚表的基础。
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(); :取出虚表中的函数指针,直接调用该虚函数,验证函数的实际实现。
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 添加的结束标志)。
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)。
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)。
int main () {
Parent p;
Child c;
Grandson g;
PrintVFT ((VFUNC*)(*(int *)(&p)));
PrintVFT ((VFUNC*)(*(int *)(&c)));
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] =0032105 F- >Parent ::func2 ()
a [0] =0032132 A- >Child ::func1 ()
a [1] =0032105 F- >Parent ::func2 ()
a [2] =0032106 E- >Child ::func3 ()
a [3] =003210 A0- >Child ::func4 ()
a [0] =0032122 B- >Grandson ::func1 ()
a [1] =0032105 F- >Parent ::func2 ()
a [2] =0032106 E- >Child ::func3 ()
a [3] =003210 A0- >Child ::func4 ()
Parent的虚表只有两个项(func1/func2),遍历到第 2 项后遇到 0终止;
Child的虚表是 '覆盖 func1+ 追加 func3/func4',共 4 个项;
Grandson仅覆盖 func1,其他项与 Child一致。
四、总结
VS 虚表的结束标志 :编译器在虚表末尾添加空指针(0),因此可用 a[i] != 0遍历,这是 VS 的专属实现细节;
虚表的核心规则 :派生类虚表 = 继承基类虚表 + 覆盖重写的虚函数地址 + 追加新增的虚函数地址 + 空指针结束;
虚表指针的提取 :利用 '有虚函数的对象首成员是虚表指针' 的内存布局,通过指针强转和解引用,从对象中提取虚表首地址;
函数指针的作用 :VFUNC类型匹配虚函数的签名,让我们能直接调用虚表中的函数指针,验证虚函数的实际实现。
这段代码的本质是手动操作虚函数表 ,从底层视角验证了 C++ 多态的实现逻辑 —— 虚函数的重写对应虚表地址的覆盖,虚表指针则决定了运行时调用的函数版本。
微信扫一扫,关注极客日志 微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
相关免费在线工具 Base64 字符串编码/解码 将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
Base64 文件转换器 将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
Markdown转HTML 将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online
HTML转Markdown 将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online
JSON 压缩 通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online
JSON美化和格式化 将JSON字符串修饰为友好的可读格式。 在线工具,JSON美化和格式化在线工具,online