多态
- 导言
- [1. 运行时多态的定义与构成条件](#1. 运行时多态的定义与构成条件)
-
- [1.1 虚函数的定义与语法规范](#1.1 虚函数的定义与语法规范)
- [1.2 虚函数的重写/覆盖(三同原则)](#1.2 虚函数的重写/覆盖(三同原则))
-
- [示例 1:买票场景的多态重写](#示例 1:买票场景的多态重写)
- [示例 2:动物叫声的多态实现](#示例 2:动物叫声的多态实现)
- [2. 虚函数重写中的经典难题与特例剖析](#2. 虚函数重写中的经典难题与特例剖析)
-
- [2.1 虚函数重写与缺省参数的静态绑定陷阱【面试常考👔】](#2.1 虚函数重写与缺省参数的静态绑定陷阱【面试常考👔】)
- [2.2 协变(Covariance)返回值重写特例](#2.2 协变(Covariance)返回值重写特例)
- [2.3 虚析构函数与内存泄漏防护【面试高频👔】](#2.3 虚析构函数与内存泄漏防护【面试高频👔】)
- [2.4 隐藏(重定义)机制与防坑指南](#2.4 隐藏(重定义)机制与防坑指南)
-
- [示例 1:函数名相同、参数不同触发隐藏](#示例 1:函数名相同、参数不同触发隐藏)
- [示例 2:函数名与参数均相同,但因未加 `virtual` 触发隐藏](#示例 2:函数名与参数均相同,但因未加
virtual触发隐藏)
- [2.5 override 与 final 关键字(C++11)](#2.5 override 与 final 关键字(C++11))
-
- [示例 1:使用 `override` 进行强制重写检查](#示例 1:使用
override进行强制重写检查) - [示例 2:使用 final 禁止虚函数重写](#示例 2:使用 final 禁止虚函数重写)
- [示例 1:使用 `override` 进行强制重写检查](#示例 1:使用
- [2.6 函数关系辨析:重载、重写(覆盖)与隐藏(重定义)](#2.6 函数关系辨析:重载、重写(覆盖)与隐藏(重定义))
- [3. 纯虚函数与抽象类](#3. 纯虚函数与抽象类)
- [4. 运行时多态的底层逻辑与虚函数表](#4. 运行时多态的底层逻辑与虚函数表)
-
- [4.1 对象内存布局与虚函数表指针(__vfptr)](#4.1 对象内存布局与虚函数表指针(__vfptr))
- [4.2 多态的底层实现原理](#4.2 多态的底层实现原理)
-
- [4.2.1 运行时多态的底层调优过程](#4.2.1 运行时多态的底层调优过程)
- [4.2.2 动态绑定与静态绑定的汇编级对比](#4.2.2 动态绑定与静态绑定的汇编级对比)
- [4.2.3 虚函数表(vtable)的构成与存放位置实测](#4.2.3 虚函数表(vtable)的构成与存放位置实测)
- [4.3 多重继承的虚表数量【面试题】](#4.3 多重继承的虚表数量【面试题】)
- [4.4 内存区域剖析实测【面试高频👔】](#4.4 内存区域剖析实测【面试高频👔】)
- [5. 章节微总结💡](#5. 章节微总结💡)
导言
多态在 C++ 中分为两大类:
-
编译时多态(静态多态): 主要指函数重载与函数模板。它们通过传入不同类型的参数,在编译期完成参数匹配与目标函数的绑定。
-
运行时多态(动态多态): 当去完成某个特定行为(函数调用)时,传入不同的对象就会完成不同的行为。例如:普通人全价买票、学生打折买票、军人优先买票;或者向接口传入
Cat对象发出 "(>^ω^<)喵",传入Dog对象发出"汪汪"。本章将聚焦于运行时多态,从语法条件、重写规则到虚函数表(vtable)的运行机制进行全方位的拆点解析。
1. 运行时多态的定义与构成条件
构成 C++ 运行时多态必须同时满足以下两个关键条件:
- 必须是基类的指针或者引用调用虚函数。
- 被调用的函数必须是虚函数,并且派生类必须对该虚函数完成重写/覆盖(Override)。
只有基类的指针或引用才能既指向基类对象,又指向派生类对象(切片机制);而只有完成了虚函数重写,派生类与基类才具备不同的函数行为,达到"指向谁就调用谁"的效果。

1.1 虚函数的定义与语法规范
在类成员函数 的前面加上 virtual 关键字修饰,该成员函数就成为了虚函数。
cpp
namespace fuyo
{
class Person
{
public:
// 成员函数前加 virtual 修饰,声明为虚函数
virtual void BuyTicket() { cout << "买票-全价" << endl; }
};
}
⭐ 语法细节与行为机制:
-
virtual的作用指令:virtual关键字本质上是给编译器下达的指令------"别只看指针或引用的静态声明类型!当有人调用这个函数时,请在运行时查验指针指向的真实对象,去调用该具体对象的方法!" -
修饰限制:
virtual只能修饰类的非静态成员函数 ,普通全局函数或静态成员函数(static函数)不能加virtual修饰。
1.2 虚函数的重写/覆盖(三同原则)
派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的 返回值类型 、函数名 、参数列表 完全相同),称派生类的虚函数重写了基类的虚函数。
示例 1:买票场景的多态重写
cpp
namespace fuyo
{
class Base {
public:
virtual void func(int x) { // 父虚函数
cout << "Base func" << endl;
}
virtual ~Base() {} // 虚析构函数,防止内存泄漏
};
class Son : public Base {
public:
// 派生类重写基类虚函数(语法上可省略 virtual,但规范写法建议加上)
void func(int x) {
cout << "Son func" << endl;
}
};
void TestVirtualOverride()
{
Base* p = new Son();
p->func(10); // 多态:调用Son::func,这就是虚函数作用
delete p;
}
}
⭐ 语法细节与行为机制:
-
标准重写(三同): 派生类
Son中的func与基类Base中的func函数名相同、参数列表相同、返回值相同,且基类函数加了virtual,成功构成虚函数重写。 -
派生类省略
virtual的隐患: 在重写基类虚函数时,派生类的虚函数即使不加 virtual 关键字也可以构成重写(因为继承后基类的虚函数属性保留了起来)。但这种写法极不规范,考试与面试中属于高频坑点,建议一律显式加上。 -
动态绑定触发: 通过基类指针
p指向派生类对象Son并调用虚函数p->func(10)时,程序不会看指针的声明类型(Base*),而是在运行时找到Son对象的虚函数表,精准调用Son::func,实现多态。
示例 2:动物叫声的多态实现
cpp
namespace fuyo
{
class Animal
{
public:
virtual void talk() const
{}
};
class Dog : public Animal
{
public:
virtual void talk() const
{
std::cout << "汪汪" << std::endl;
}
};
class Cat : public Animal
{
public:
virtual void talk() const
{
std::cout << "(>^ω^<)喵" << std::endl;
}
};
void letsHear(const Animal& animal)
{
animal.talk(); // 基类 const 引用触发多态
}
void TestAnimal()
{
Cat cat;
Dog dog;
letsHear(cat); // 输出:(>^ω^<)喵
letsHear(dog); // 输出:汪汪
}
}
⭐ 语法细节与行为机制:
- 常量引用触发:
const Animal&同样能触发多态,重点在于letsHear函数形参是基类引用const Animal&,传入cat绑定Cat::talk(),传入dog绑定Dog::talk()。 - 且
talk()被修饰为virtual并保持参数与const属性完全一致。❌️错误反例:
如果letsHear写成Animal animal(值传递 ),会发生对象切片 ,永远调用Animal::talk,没有多态。
cpp
void letsHear(Animal animal) // ❌ 值传递,切片!
{
animal.talk();
}
2. 虚函数重写中的经典难题与特例剖析
2.1 虚函数重写与缺省参数的静态绑定陷阱【面试常考👔】
试分析以下程序的输出结果:
cpp
namespace fuyo
{
class A
{
public:
virtual void func(int val = 1) { std::cout << "A->" << val << std::endl; }
virtual void test() { func(); }
};
class B : public A
{
public:
void func(int val = 0) { std::cout << "B->" << val << std::endl; }
};
}
int main(int argc, char* argv[])
{
fuyo::B* p = new fuyo::B;
p->test();
p->func();
return 0;
}
✅️正确答案: p->test() 输出 B->1;p->func() 输出 B->0。
⭐ 语法细节与行为机制拆解:
对于 p->test() 的执行流程,拆解为以下 4 个步骤:
-
寻找
test()函数:p的类型是B*。由于类B没有重写test(),直接调用继承自基类的A::test()。 -
确定
test()内部的this指针:在
A::test()内部,隐式调用的func()实际上是this->func()。此时this指针的静态类型是A*,但指向的动态类型依然是new B创建出来的B类对象。 -
决定执行哪个函数体(动态绑定):
由于
func()被声明为virtual,根据this指针指向的实际对象类型(B)查找虚表,最终调用的函数体是B::func()(控制台打印B->)。 -
决定缺省参数 val 的值(静态绑定 - 最关键点📌):
缺省参数的值在编译期就已经固定了 ,编译器只会根据表达式的静态类型 决定填入什么默认值。在
A::test()函数内部调用this->func()时,this指针的静态类型是A*。编译器在编译A::test()时,看到A类中func(int val = 1)的默认值是1,直接把参数 1 压入栈中,调用被替换为:this->func(1)。
💡 核心总结:
-
实际指向的对象(动态类型) ,决定调用的函数体 (
B::func())。 -
指针本身的类型(静态类型) ,决定缺省参数 的取值(采用
A::func中的缺省值1)。
2.2 协变(Covariance)返回值重写特例
派生类重写基类虚函数时,与基类虚函数返回值类型不同。
即基类虚函数 返回基类对象 的指针或者引用,派生类虚函数 返回派生类对象 的指针或者引用时,称为协变。
cpp
namespace fuyo
{
class A {};
class B : public A {};
// 协变示例
class Person {
public:
virtual A* BuyTicket()
{
cout << "买票-全价" << endl;
return nullptr;
}
};
class Student : public Person {
public:
// 协变:返回值由 A* 变为了 B* (B 继承自 A)
virtual B* BuyTicket()
{
cout << "买票-打折" << endl;
return nullptr;
}
};
void Func(Person* ptr)
{
ptr->BuyTicket();
}
void TestCovariance()
{
Person ps;
Student st;
Func(&ps); // 输出:买票-全价
Func(&st); // 输出:买票-打折
}
}
⭐ 语法细节与行为机制:
- 协变约束: 派生类虚函数返回的指针/引用所指向的类(如
B),必须继承自基类虚函数返回的指针/引用所指向的类(如A),否则无法构成重写,编译直接报错。
2.3 虚析构函数与内存泄漏防护【面试高频👔】
基类的析构函数建议声明为虚函数。
此时派生类析构函数只要定义,无论是否加 virtual 关键字,都与基类的析构函数构成重写。
cpp
namespace fuyo
{
class A
{
public:
virtual ~A()
{
cout << "~A()" << endl;
}
};
class B : public A {
public:
// 构成重写:编译器将析构函数名统一处理为 destructor()
~B()
{
cout << "~B()->delete:" << _p << endl;
delete[] _p; // 释放派生类资源
}
protected:
int* _p = new int[10];
};
void TestVirtualDestructor()
{
A* p1 = new A;
A* p2 = new B;
// 执行 delete 时:先调用析构函数,再调用 operator delete 释放内存
delete p1;
delete p2; // 触发多态,正确先调用 ~B() 再调用 ~A()
}
}
⭐ 语法细节与行为机制:
-
统一命名处理: 虽然基类与派生类析构函数名字看起来不同,但编译器对析构函数名称做了特殊处理,编译后统一处理成
destructor()。因此只要基类加了virtual,派生类析构函数就自动构成重写。 -
内存泄漏风险: 如果
~A()不加virtual,执行delete p2时只会根据静态类型A*调用~A(),不会调用~B(),导致派生类中_p申请的堆内存无法释放,引发严重内存泄漏。
👔 面试常考:为什么基类的析构函数建议声明为虚函数?
✅️答: 只有把基类的析构函数设计为虚函数,通过基类指针 delete 派生类对象时,才能触发多态,保证先调用派生类的析构函数清理派生类资源,再调用基类的析构函数,防止资源泄露。
2.4 隐藏(重定义)机制与防坑指南
在继承体系中,当派生类定义了与基类同名 的函数,且不构成重写 时,就会触发隐藏(Hide / 重定义)。
示例 1:函数名相同、参数不同触发隐藏
cpp
namespace fuyo
{
class Person {
public:
// 基类中的 BuyTicket 带有 int 参数
void BuyTicket(int a) { cout << "Person:买票-" << a << endl; }
};
class Student : public Person {
public:
// 派生类中的 BuyTicket 没有参数,且基类函数未加 virtual
// 触发隐藏!基类的 BuyTicket(int) 被隐藏掉了
void BuyTicket() { cout << "Student:买票-打折" << endl; }
};
void TestHide()
{
Student st;
st.BuyTicket(); // ✅ 默认调用的是 Student 自己的 BuyTicket()
// st.BuyTicket(10); // ❌ 编译报错!报错信息:函数不接受 1 个参数
// 原因:只要派生类定义了同名函数 BuyTicket,基类的所有同名函数就会全部被隐藏!
st.Person::BuyTicket(10); // ✅ 必须通过显式指定作用域,才能调用到被隐藏的基类函数
}
}
⭐ 语法细节与行为机制:
-
隐藏的核心规则: 只要函数名相同,且不构成重写(比如没加
virtual,或者参数列表不同),派生类的函数就会将基类的同名函数隐藏起来。 -
作用域屏蔽: 编译器在查找函数时是自下而上 (先去派生类 作用域找,再去基类 作用域)查找的。一旦在派生类作用域里找到了名为
BuyTicket的函数,编译器就会停在该作用域进行参数匹配。此时如果发现形参与传入的实参不匹配(如传入了int),编译器会直接报错,而不会再去基类作用域里搜寻。
示例 2:函数名与参数均相同,但因未加 virtual 触发隐藏
cpp
namespace fuyo
{
class Person {
public:
// 没有加 virtual 关键字
void func(int x) { cout << "Person func: " << x << endl; }
};
class Student : public Person {
public:
// 与基类函数"三同",但因为基类没有 virtual,不构成重写,而是隐藏!
void func(int x) { cout << "Student func: " << x << endl; }
};
void TestHideSameParam()
{
Student st;
st.func(10); // 输出:Student func: 10(调用派生类自己的函数)
Person* ptr = &st;
ptr->func(10); // 输出:Person func: 10(无法触发多态,静态绑定调用基类函数)
}
}
⭐ 语法细节与行为机制:
-
重写 vs 隐藏 的判定分界:
-
重写(覆盖) :基类必须有
virtual+ 函数名相同 + 参数列表相同 + 返回值相同(协变除外)。 -
隐藏(重定义):只要函数名相同,且不满足重写的条件,就统一属于隐藏!
-
-
指针调用的差异:隐藏场景下不具备动态绑定特性。通过基类指针
Person*调用时,编译器仅根据静态类型Person*去寻找Person类内部的函数,因此无法达到多态的效果。
2.5 override 与 final 关键字(C++11)
从上面的代码可以看出,C++ 对虚函数重写的要求比较严格。
在传统 C++ 中,由于疏忽(比如函数名写错、参数写错)导致无法构成重写时,编译器在编译期间不会报错,只有在程序运行时没有得到预期结果才去调试,效率极低。
因此 C++11 引入了 override 关键字,专门用于帮助程序员明确表示重写意图,并让编译器帮我们做严谨的语法检查。
示例 1:使用 override 进行强制重写检查
cpp
namespace fuyo
{
class Car {
public:
virtual void Drive() {}
};
class Benz : public Car {
public:
// 如果基类中拼错成了 Dirve(),增加 override 后编译器会直接报 C3668 错误
// 报错信息:包含重写说明符"override"的方法没有重写任何基类方法
virtual void Drive() override { cout << "Benz-舒适" << endl; }
};
}
⭐ 语法细节与行为机制:
- 重写校验:
override放置在派生类虚函数声明末尾,强制编译器检查该函数是否成功重写了基类的某个虚函数。如果基类中不存在对应的虚函数,编译期报错。
示例 2:使用 final 禁止虚函数重写
如果我们不希望后续的派生类重写某个虚函数,可以用 final 关键字去修饰该虚函数。
cpp
namespace fuyo
{
class Car {
public:
// 使用 final 修饰,禁止后续派生类重写该虚函数
virtual void Drive() final
{}
};
class Benz : public Car {
public:
// ❌️编译报错:error C3248: "Car::Drive": 声明为"final"的函数无法被"Benz::Drive"重写
// virtual void Drive() { cout << "Benz-舒适" << endl; }
};
}
⭐ 语法细节与行为机制:
- 终止重写:
final放在虚函数声明末尾,封死该虚函数的进一步继承覆盖,派生类若尝试重写将直接报出C3248错误。
2.6 函数关系辨析:重载、重写(覆盖)与隐藏(重定义)

3. 纯虚函数与抽象类
在虚函数的后面写上 = 0,这个函数就是纯虚函数 。包含纯虚函数的类叫做抽象类。
纯虚函数不需要定义实现(实现没太大意义,因为要被派生类重写,但是语法上可以实现),只要声明即可。
cpp
namespace fuyo
{
class Car
{
public:
// 纯虚函数:无须具体实现,强制派生类重写
virtual void Drive() = 0;
};
class Benz : public Car
{
public:
// 重写纯虚函数后,Benz 不再是抽象类,可以实例化
virtual void Drive()
{
cout << "Benz-舒适" << endl;
}
};
class BMW : public Car
{
public:
virtual void Drive()
{
cout << "BMW-操控" << endl;
}
};
void TestAbstractClass()
{
// 编译报错:error C2259: "Car": 无法实例化抽象类
// Car car;
// 抽象类指针可以指向具体派生类的对象
Car* pBenz = new Benz;
pBenz->Drive(); // 输出:Benz-舒适
Car* pBMW = new BMW;
pBMW->Drive(); // 输出:BMW-操控
}
}
⭐ 语法细节与行为机制:
-
抽象类限制: 抽象类无法实例化出对象,如果派生类继承后不重写纯虚函数,那么派生类也是抽象类,除非派生类重写了继承来的全部纯虚函数。
-
接口继承: 纯虚函数在语法上强制了派生类必须重写该虚函数 ,体现了"接口继承"的思想。
也就是说,可以利用抽象类,让每一个子类都重写一个函数,使同一个函数名字,不同子类表现不同行为。
4. 运行时多态的底层逻辑与虚函数表
4.1 对象内存布局与虚函数表指针(__vfptr)
在理解多态底层是如何运作之前,我们先来看一道极其经典的 C++ 笔试/面试题:
经典题目实测【面试高频👔】
在 32 位系统环境编译下,以下程序的运行输出结果是什么( )
A. 编译报错 B. 运行报错 C. 8 D. 12
cpp
#include <iostream>
using namespace std;
namespace fuyo
{
class Base
{
public:
virtual void Func1()
{
cout << "Func1()" << endl;
}
protected:
int _b = 1;
char _ch = 'x';
};
}
int main()
{
fuyo::Base b;
cout << sizeof(b) << endl; // 计算类对象 b 的占用内存大小
return 0;
}
✅️正确答案: D. 12
⭐ 语法细节与行为机制拆解:
- 内存对齐基础计算
-
成员变量
_b的类型是int,在 32 位系统下占 4 字节。 -
成员变量
_ch的类型是char,占 1 字节。 -
按照结构体/类成员的内存对齐规则,整体按
int(4 字节)对齐,_b和_ch合计占用 8 字节。
- 多出来的 4 字节是什么?
如果类中没有虚函数,sizeof(b)应该是 8。但运行结果却是 12 bytes,说明对象内部多存了一个 4 字节的指针!
这个指针通常存放在对象内存模型的最前面(某些特定平台或编译器可能会放在最后面,VS 编译器默认放在最首部),它的名字叫做虚函数表指针 (Virtual Function Table Pointer),在调试窗口中通常显示为 __vfptr:
-
v 代表 virtual(虚);
-
f 代表 function(函数);
-
ptr 代表 pointer(指针)。
- 虚函数表(vtable)的本质
只要一个类中包含了虚函数(哪怕只有一个),编译器就会在创建对象时,隐式地在对象首部插入一个__vfptr指针。
这个 __vfptr 指向一个函数指针数组 (即虚函数表 ,简称虚表 vtable),里面存放着该类中所有虚函数的入口指令地址,它在编译期 就生成,放在只读常量区,不是运行时新建的。
4.2 多态的底层实现原理
4.2.1 运行时多态的底层调优过程
从底层的角度来看,在 Func 函数中执行 ptr->BuyTicket() 时,编译器是如何做到:
-
当
ptr指向Person对象时,调用Person::BuyTicket; -
当
ptr指向Student对象时,调用Student::BuyTicket; -
当
ptr指向Soldier对象时,调用Soldier::BuyTicket?
cpp
#include <iostream>
#include <string>
using namespace std;
namespace fuyo
{
class Person {
public:
virtual void BuyTicket() { cout << "买票-全价" << endl; }
private:
string _name;
};
class Student : public Person {
public:
virtual void BuyTicket() { cout << "买票-打折" << endl; }
private:
string _id;
};
class Soldier : public Person {
public:
virtual void BuyTicket() { cout << "买票-优先" << endl; }
private:
string _codename;
};
void Func(Person* ptr)
{
// 核心机制:虽然调用的发起者都是 Person* 类型的 ptr,
// 但真正调用的函数完全取决于 ptr 运行时所指向对象的真实类型!
ptr->BuyTicket();
}
void TestPolymorphismMechanism()
{
Person ps;
Student st;
Soldier sr;
Func(&ps); // 输出:买票-全价
Func(&st); // 输出:买票-打折
Func(&sr); // 输出:买票-优先
}
}
⭐ 底层寻址原理剖析:
-
静态编译阶段:满足多态条件后,编译器不再在编译期根据指针的声明类型(Person*)写死调用的函数地址。
-
运行寻址阶段:程序执行到 ptr->BuyTicket() 时,通过以下 3 步在运行期动态确定地址:
-
找指针:通过指针 ptr 访问指向的对象首部,取出对象头部的 4 个字节,即虚表指针 __vfptr。
-
查虚表:顺着 __vfptr 找到该对象独有的虚函数表(vtable)。
-
定位函数:在虚表中取出偏移量对应的虚函数入口地址,执行 call 指令。
这使得指针指向哪个对象,就能精准拿到那个对象虚表中重写后的虚函数地址。
4.2.2 动态绑定与静态绑定的汇编级对比
在 C++ 中,函数调用在底层的绑定方式分为两大类:
-
静态绑定(静态多态): 对不满足多态条件 的普通函数(或通过对象直接调用),编译器在编译阶段就能直接确定函数的调用地址。
-
动态绑定(动态多态): 对满足多态条件 (基类指针/引用 + 调用虚函数)的调用,编译器在编译期无法确定地址,必须生成底层指令,在运行期去对象的虚函数表中动态查表定位。
我们将两种场景下的底层 x86 汇编代码进行反汇编对比:
- 满足多态场景(动态绑定汇编)
cpp
ptr->BuyTicket();
对应反汇编指令:
00EF2001 mov eax, dword ptr [ptr] ; 1. 取出 ptr 指向对象的首地址(即 __vfptr 的地址)
00EF2004 mov edx, dword ptr [eax] ; 2. 取出 __vfptr 的值,即虚表 vtable 的首地址
00EF2006 mov esi, esp
00EF2008 mov ecx, dword ptr [ptr] ; 3. 将 ptr 作为 this 指针存入 ecx 寄存器
00EF200B mov eax, dword ptr [edx] ; 4. 取出虚表中第 1 项虚函数 (BuyTicket) 的入口地址
00EF200D call eax ; 5. 间接调用 eax 寄存器中存放在运行期查到的函数地址
⭐ 关键机制: call eax 是间接寻址指令,调用的地址保存在寄存器 eax 中,而 eax 的值来自于运行时查到的虚表。
- 不满足多态场景(静态绑定汇编)
cpp
ptr->BuyTicket(); // 假设 BuyTicket 不是虚函数
对应反汇编指令:
00EA2C91 mov ecx, dword ptr [ptr] ; 1. 将 ptr 作为 this 指针压入 ecx 寄存器
00EA2C94 call Person::BuyTicket (0EA153Ch) ; 2. 直接 call 编译器预先写死的绝对地址
⭐ 关键机制: call 0EA153Ch 是直接寻址指令,调用的函数地址在编译链接阶段就已经硬编码固定。
4.2.3 虚函数表(vtable)的构成与存放位置实测
虚函数表的核心生成法则
- 共享与独立性:
-
同一个类的所有对象,共享同一张虚函数表;
-
不同的类(如 Person 与 Student)各自拥有独立的虚函数表。
- 派生类虚表的继承逻辑:
- 派生类由"继承自基类的部分"与"自己新增的成员"构成。派生类对象中包含继承下来的基类虚表指针,但派生类对象的虚表内容是全新生成且独立保存的。
- 派生类虚表的内容组成(三部分):
-
未被重写的基类虚函数地址;
-
已经被派生类覆盖重写的虚函数地址;
-
派生类自己新增的虚函数地址。
- 虚表结尾标记:
虚表本质上是一个存储虚函数指针的指针数组(如 void(*)() 数组)。在 VS 系列编译器下,虚表数组的末尾默认以 nullptr(即 0x00000000)作为结束标记(GCC/g++ 编译器不一定有此标记)。
4.3 多重继承的虚表数量【面试题】
题目:假设D类先继承B1,然后继承B2,B1和B2基类均包含虚函数,D类对B1和B2基类的虚函数重写了,并且D类增加了新的虚函数,则:( )
A.D类对象模型中包含了3个虚表指针
B.D类对象有两个虚表,D类新增加的虚函数放在第一张虚表最后
C.D类对象有两个虚表,D类新增加的虚函数放在第二张虚表最后
D.以上全部错误
✅️正确答案:B.D类对象有两个虚表,D类新增加的虚函数放在第一张虚表最后
⭐ 语法细节与行为机制拆解:
-
B1、B2 各自都有虚函数 → D 会生成两张虚表,对应两个父类
- 第 1 张虚表:对应 B1 子对象
- 第 2 张虚表:对应 B2 子对象
-
D 自己新增的虚函数,追加到第一张虚表(B1 对应的那张)末尾,不会新开一张虚表,也不会放到第二张。
⭐结论:
多重继承中
- 多个带虚函数的父类 → 父类数量 = 虚表数量;
- 子类新增的虚函数,统一挂靠在第一个父类对应的虚表后面。
4.4 内存区域剖析实测【面试高频👔】
🤔疑问:虚函数和虚函数表分别存放在内存的哪个区域?
-
虚函数:和普通函数一样,编译后是一段二进制 CPU 指令,存放在代码段(代码区)。
-
虚函数表:C++ 标准未规定虚表的存储区域。我们通过打印对象的虚表首地址,与栈区、堆区、静态区、常量区(代码段)进行地址对比测算:
cpp
#include <iostream>
#include <cstdio>
using namespace std;
namespace fuyo
{
class Base {
public:
virtual void func1() { cout << "Base::func1" << endl; }
virtual void func2() { cout << "Base::func2" << endl; }
void func5() { cout << "Base::func5" << endl; }
protected:
int a = 1;
};
class Derive : public Base
{
public:
// 重写基类的 func1
virtual void func1() { cout << "Derive::func1" << endl; }
virtual void func3() { cout << "Derive::func3" << endl; }
void func4() { cout << "Derive::func4" << endl; }
protected:
int b = 2;
};
void TestVtableLocation()
{
int i = 0;
static int j = 1;
int* p1 = new int;
const char* p2 = "xxxxxxxx";
printf("栈区地址: %p\n", &i);
printf("静态区地址: %p\n", &j);
printf("堆区地址: %p\n", p1);
printf("常量区地址: %p\n", p2);
Base b;
Derive d;
Base* p3 = &b;
Derive* p4 = &d;
// 取出对象头部的前 4 个字节,即指针 __vfptr 的内容(虚表首地址)
printf("Base 虚表地址: %p\n", *(int*)p3);
printf("Derive 虚表地址: %p\n", *(int*)p4);
printf("虚函数地址: %p\n", &Base::func1);
printf("普通函数地址: %p\n", &Base::func5);
delete p1;
}
}
int main()
{
fuyo::TestVtableLocation();
return 0;
}
⭐ 在 VS (32位) 环境下的运行结果与推导分析:
cpp
栈区地址: 010FF954
静态区地址: 0071D000
堆区地址: 0126D740
常量区地址: 0071ABA4
Base 虚表地址: 0071AB44
Derive 虚表地址: 0071AB84
虚函数地址: 00711488
普通函数地址: 007114BF
⭐实测结论:
-
Base 虚表地址 (0071AB44) 与 Derive 虚表地址 (0071AB84) 的数值极度接近 常量区地址 (0071ABA4),这证明在 VS 编译器下,虚函数表存在代码段(常量区)。
-
虚函数地址 (00711488) 和 普通函数地址 (007114BF) 存放位置非常相近,证明虚函数与普通函数一样存放在代码段中,虚表中保存的仅仅是虚函数的入口地址指针。
5. 章节微总结💡
