目录
- 一、引言:为什么这些知识如此重要
- 二、菱形虚拟继承原理深度剖析
- [2.1 问题起源:菱形继承的致命缺陷](#2.1 问题起源:菱形继承的致命缺陷)
- [2.2 虚继承是如何解决问题的](#2.2 虚继承是如何解决问题的)
- [2.3 深入内存模型------见证真相的时刻](#2.3 深入内存模型——见证真相的时刻)
- [2.4 虚基表指针的统一访问方式](#2.4 虚基表指针的统一访问方式)
- [2.5 动手验证------写出菱形虚拟继承模型](#2.5 动手验证——写出菱形虚拟继承模型)
- 三、虚函数表深入探索------揭开多态的神秘面纱
- [3.1 重温多态与虚函数表的基础](#3.1 重温多态与虚函数表的基础)
- [3.2 单继承虚函数表------监视窗口骗了你](#3.2 单继承虚函数表——监视窗口骗了你)
- [3.3 多继承虚函数表------更复杂的真相](#3.3 多继承虚函数表——更复杂的真相)
- [3.4 多继承中的 this 指针修正------最精妙的细节](#3.4 多继承中的 this 指针修正——最精妙的细节)
- [3.5 菱形继承与菱形虚拟继承的虚表](#3.5 菱形继承与菱形虚拟继承的虚表)
- 四、反向迭代器------适配器设计模式的经典运用
- [4.1 SGI-STL 源码框架分析](#4.1 SGI-STL 源码框架分析)
- [4.2 手把手实现通用反向迭代器](#4.2 手把手实现通用反向迭代器)
- [4.3 集成到 vector 和 list](#4.3 集成到 vector 和 list)
- 五、继承和多态高频面试题精讲
- 六、总结与思考
一、引言
这篇文章将基于 SGI-STL 源码分析,带你深入 C++ 继承与多态的底层世界。文章会从菱形虚拟继承的内存模型 讲到单继承和多继承的虚函数表结构,力求让你读完后有一种"原来如此"的通透感。
二、菱形虚拟继承原理深度剖析
2.1 问题起源:菱形继承的"致命缺陷"
在 C++ 中,一个类可以同时继承多个父类,这就是多继承 。多继承带来了灵活性,但也引入了一个经典问题------菱形继承(Diamond Inheritance)。
看下面这个继承体系:
cpp
class Person {
public:
string _name; // 姓名
};
class Student : public Person {
protected:
int _num; // 学号
};
class Teacher : public Person {
protected:
int _id; // 职工编号
};
class Assistant : public Student, public Teacher {
protected:
string _majorCourse; // 主修课程
};
这个继承体系形成了一个菱形结构:
Person
/ \
Student Teacher
\ /
Assistant
问题一:二义性(Ambiguity)
cpp
Assistant a;
a._name = "peter"; // 编译错误!编译器不知道访问哪一个 _name
因为 Student 和 Teacher 各自继承了一份 Person 的 _name,所以在 Assistant 对象中存在两份 _name。编译器无法确定你指的是 Student::_name 还是 Teacher::_name。
问题一的"半解决"------显式指定作用域:
cpp
a.Student::_name = "xxx";
a.Teacher::_name = "yyy";
但这只是解决了二义性,数据冗余的问题依然存在------同一个人的姓名被存了两份!这显然不合理。
2.2 虚继承是如何解决问题的
C++ 引入了虚继承 (Virtual Inheritance)来解决菱形继承的数据冗余和二义性问题。关键字是 virtual:
cpp
// 关键改动:Student 和 Teacher 虚继承 Person
class Student : virtual public Person {
protected:
int _num;
};
class Teacher : virtual public Person {
protected:
int _id;
};
class Assistant : public Student, public Teacher {
protected:
string _majorCourse;
};
核心区别 :使用虚继承后,
Person这个虚基类 在Assistant对象中只存在一份。
此时再访问 _name 就没有二义性了:
cpp
Assistant a;
a._name = "peter"; // 成功!只有一份 _name
2.3 深入内存模型------见证真相的时刻
重要提示 :VS 编译器的监视窗口 经过了特殊处理,它展示的对象模型是"美化"过的,并不是真实的内存布局。要看清真实的内存模型,必须借助内存窗口 (快捷键:调试时
Ctrl+Alt+M,1)。
为了研究虚继承的底层原理,我们使用以下简化版的菱形虚拟继承模型:
cpp
class A {
public:
int _a;
};
class B : virtual public A {
public:
int _b;
};
class C : virtual public A {
public:
int _c;
};
class D : public B, public C {
public:
int _d;
};
通过内存窗口观察 D 对象的真实布局,我们发现:
D 对象内存模型(概念图):
B 部分:
+----------------------+
| vbptr_B -> 虚基表B | <- 指向虚基表(存储偏移量)
| _b | <- B 自己的成员
+----------------------+
C 部分:
| vbptr_C -> 虚基表C | <- 指向虚基表(存储偏移量)
| _c | <- C 自己的成员
+----------------------+
| _d | <- D 自己的成员
+----------------------+
A 部分(公共的虚基类):
| _a | <- 只有一份!
+----------------------+
关键发现:
B和C部分各自包含一个虚基表指针 (vbptr),指向各自的虚基表(virtual base table);- 虚基表中存储的是当前部分距离公共虚基类 A 的偏移量;
- 公共的虚基类
A部分在D对象中只有一份------这就是数据冗余被消除的原因。
2.4 虚基表指针的统一访问方式
一个非常精妙的设计细节:不仅是 D 对象,B 自己的对象模型也采用了同样的虚基表访问方式。
cpp
B b;
b._a = 7; // B 对象访问 _a 同样通过虚基表指针
b._b = 8;
D d;
d._a = 3;
d._b = 4;
B* p1 = &d; // B 的指针指向 D 对象(切片)
B* p2 = &b; // B 的指针指向 B 对象
当我们打开汇编代码观察时,会发现:
cpp
p1->_a++; // 通过虚基表指针 -> 查偏移量 -> 访问 _a
p2->_a++; // 通过虚基表指针 -> 查偏移量 -> 访问 _a
两条语句生成的汇编代码完全相同! 这就是统一访问方式的精妙之处------无论 B 的指针指向的是 B 自己还是 D 的切片,访问 _a 成员都通过虚基表指针来定位。这种一致性保证了指针切片后的正确行为。
2.5 动手验证------写出菱形虚拟继承模型
完整验证代码:
cpp
class A {
public:
int _a;
};
class B : virtual public A {
public:
int _b;
};
class C : virtual public A {
public:
int _c;
};
class D : public B, public C {
public:
int _d;
};
int main() {
D d;
d.B::_a = 1; // 通过 B 的虚基表访问 _a
d.C::_a = 2; // 通过 C 的虚基表访问 _a ------ 但这是同一个 _a!
d._a = 3; // 最终 _a 的值是 3(前面的 1 和 2 都被覆盖了)
d._b = 4;
d._c = 5;
d._d = 6;
// 验证:B 对象也使用虚基表访问 A
B b;
b._a = 7;
b._b = 8;
B* p1 = &d; // 指向 D 对象(切片视角)
B* p2 = &b; // 指向 B 对象
p1->_a++; // p1 和 p2 的 _a 访问方式是相同的
p2->_a++;
return 0;
}
小结 :菱形虚拟继承通过虚基表 (存储偏移量)实现了公共基类的唯一存储。
B和C的虚基表中记录了各自部分到公共A的偏移距离,无论指针如何切片,都能正确定位到虚基类成员。
三、虚函数表深入探索------揭开多态的神秘面纱
3.1 重温多态与虚函数表的基础
回顾多态的基础知识:
- 带有虚函数的类,每个对象的前 4 个字节(32 位)或 8 个字节(64 位)存储一个虚函数表指针 (vfptr),指向该类的虚函数表(vftable)。
- 虚函数表本质是一个函数指针数组,存储了该类所有虚函数的地址。
- 当通过基类指针/引用调用虚函数时,运行时会从虚表中查找实际应该调用的函数------这就是多态的底层实现。
C++ 祖师爷 Bjarne Stroustrup 设计虚函数机制的目标:在不牺牲效率的前提下,支持运行时多态。虚函数表的引入,使得多态调用的额外开销仅为一次间接寻址。
然而,VS 的监视窗口并不会展示虚表的全貌。为了看到真相,我们需要直接读内存。
3.2 单继承虚函数表------监视窗口骗了你
先看单继承的场景:
cpp
class Base {
public:
virtual void func1() { cout << "Base::func1" << endl; }
virtual void func2() { cout << "Base::func2" << endl; }
private:
int a;
};
class Derive : public Base {
public:
virtual void func1() { cout << "Derive::func1" << endl; } // 重写 func1
virtual void func3() { cout << "Derive::func3" << endl; } // 新增虚函数
virtual void func4() { cout << "Derive::func4" << endl; } // 新增虚函数
void func5() { cout << "Derive::func5" << endl; } // 普通函数
private:
int b;
};
在监视窗口中 ,你可能只能看到 func1 和 func2,而 func3 和 func4 却不翼而飞了!
真相是 :func3 和 func4 就在虚表中,只是被监视窗口"隐藏"了。下面我们写一段代码,直接读取对象内存,强行打印虚函数表:
cpp
typedef void(*VFPTR)(); // 定义虚函数指针类型
void PrintVTable(VFPTR vTable[]) {
cout << " 虚表地址>" << vTable << endl;
// VS 下虚表末尾以 nullptr 结尾,G++ 下则不一定
for (int i = 0; vTable[i] != nullptr; ++i) {
printf(" 第%d个虚函数地址:0X%p,->", i, vTable[i]);
VFPTR f = vTable[i];
f(); // 直接调用,看看打印的是什么
}
cout << endl;
}
int main() {
Base b;
Derive d;
// 32位程序的访问思路:
// 1. 取对象地址,强转为 int*,解引用取到前 4 字节的值 -> 这就是虚表指针
// 2. 将虚表指针强转为 VFPTR* -> 虚函数指针数组
// 3. 传给 PrintVTable 打印
VFPTR* vTable1 = (VFPTR*)(*(int*)&b);
PrintVTable(vTable1);
// 输出:
// 虚表地址>0x...
// 第0个虚函数地址:0x...,->Base::func1
// 第1个虚函数地址:0x...,->Base::func2
VFPTR* vTable2 = (VFPTR*)(*(int*)&d);
PrintVTable(vTable2);
// 输出:
// 虚表地址>0x...
// 第0个虚函数地址:0x...,->Derive::func1 <- 重写的
// 第1个虚函数地址:0x...,->Base::func2 <- 未重写,保持基类的
// 第2个虚函数地址:0x...,->Derive::func3 <- Derive 新增的!
// 第3个虚函数地址:0x...,->Derive::func4 <- Derive 新增的!
return 0;
}
关键发现:
- 重写的虚函数 会替换掉虚表中原来基类虚函数的地址(
func1从Base::变成了Derive::); - 未重写的虚函数 保持基类的地址不变(
func2仍然是Base::func2); - 派生类新增的虚函数 会被追加到虚表末尾(
func3和func4都在表中); func5是普通成员函数,不在虚表中。
踩坑提醒 :这段强制打印虚表的代码有时会崩溃 。原因是编译器在许多场景下不会在虚表末尾放置
nullptr(VS 调试模式下会放,但有时"不干净"),导致循环越界。解决方法是:点击菜单栏 生成 -> 清理解决方案,再重新编译运行即可。
3.3 多继承虚函数表------更复杂的真相
多继承时的虚表结构更为复杂。看下面的例子:
cpp
class Base1 {
public:
virtual void func1() { cout << "Base1::func1" << endl; }
virtual void func2() { cout << "Base1::func2" << endl; }
private:
int b1;
};
class Base2 {
public:
virtual void func1() { cout << "Base2::func1" << endl; }
virtual void func2() { cout << "Base2::func2" << endl; }
private:
int b2;
};
class Derive : public Base1, public Base2 {
public:
virtual void func1() { cout << "Derive::func1" << endl; } // 同时重写两个基类的 func1
virtual void func3() { cout << "Derive::func3" << endl; } // 新增虚函数
private:
int d1;
};
多继承的内存布局:
Derive 对象内存模型:
Base1 部分(先继承,放在前面):
+----------------------+
| vfptr1 -> 虚表1 | <- Base1 的虚函数表指针
| b1 |
+----------------------+
Base2 部分(后继承,放在后面):
| vfptr2 -> 虚表2 | <- Base2 的虚函数表指针
| b2 |
+----------------------+
| d1 | <- Derive 自己的成员
+----------------------+
关键规律:
- 多继承时,Derive 对象包含多张虚表------继承了几个带虚函数的基类,就有几张虚表;
- 派生类新增的虚函数放在先继承的基类的虚表中 ------例如
func3放在 Base1 的虚表末尾; - 重写的虚函数
func1同时替换了两张虚表中对应的位置。
验证代码:
cpp
int main() {
Derive d;
// 打印第一张虚表(Base1 部分)
VFPTR* vTableb1 = (VFPTR*)(*(int*)&d);
PrintVTable(vTableb1);
// 第0个: Derive::func1 <- 重写了 Base1::func1
// 第1个: Base1::func2 <- 未重写
// 第2个: Derive::func3 <- Derive 新增,放在先继承的 Base1 虚表中
// 打印第二张虚表(Base2 部分)
// Base2 在内存中紧挨着 Base1,需要偏移 sizeof(Base1)
VFPTR* vTableb2 = (VFPTR*)(*(int*)((char*)&d + sizeof(Base1)));
PrintVTable(vTableb2);
// 第0个: ??? <- 注意!这个地址和 vTableb1[0] 不一样!
// 第1个: Base2::func2 <- 未重写
return 0;
}
3.4 多继承中的 this 指针修正------最精妙的细节
细心的同学会发现:Derive 重写的 func1 在两张虚表中的地址居然不一样!这是为什么?
cpp
Base1* p1 = &d;
p1->func1(); // 多态调用
Base2* p2 = &d;
p2->func1(); // 多态调用
d.func1(); // 普通调用(不经过虚表)
真相揭秘:
当 Base2* p2 = &d; 时,由于 Base2 部分在 Derive 对象中间位置,指针会发生偏移 ------p2 指向的是 Base2 子对象的起始位置,而不是 Derive 对象的起始位置。
当多态调用 p2->func1() 时:
func1是被Derive重写的,函数体内的this应该指向 Derive 对象的开头;- 但
p2指向的是 Base2 子对象的开头(发生了偏移); - 所以在调用前,this 指针需要被修正回去。
解决方案 :Base2 虚表中的 func1 地址并不是真正的 Derive::func1,而是一个包装过的 thunk 函数(或者是一条调整指令),它的工作是:
1. 先将 this 指针修正回 Derive 对象的起始地址
2. 再跳转到真正的 Derive::func1
这个过程涉及相当的汇编知识。如果你有兴趣深究,可以在 VS 中打断点,按
Ctrl+F11查看反汇编代码,会看到sub ecx, N这样的 this 调整指令。
这就是为什么两张虚表中 func1 的地址不同------Base1 虚表中的直接指向 Derive::func1(因为 Base1 就在 Derive 开头,不需要修正),而 Base2 虚表中的指向了一个 thunk(因为需要先修正 this)。
3.5 菱形继承与菱形虚拟继承的虚表
温馨提示 :菱形继承 + 虚函数的组合极为复杂,实际开发中强烈不推荐 设计出这样的继承体系。一方面太复杂容易出 bug,另一方面访问基类成员有一定的性能损耗(需要间接查表)。
这里给出一个菱形虚拟继承 + 虚函数的示例代码,你可以通过内存窗口自行观察:
cpp
class A {
public:
virtual void func1() {}
public:
int _a;
};
class B : virtual public A {
public:
virtual void func1() {}
virtual void func2() {}
public:
int _b;
};
class C : virtual public A {
public:
virtual void func1() {}
virtual void func3() {}
public:
int _c;
};
class D : public B, public C {
public:
virtual void func1() {} // 重写
virtual void func4() {} // 新增
public:
int _d;
};
如果好奇心强想深入研究的同学,推荐阅读:
四、和多态高频面试题精讲
以下是 C++ 面试中关于继承和多态的高频考题,我将每个问题的核心要点提炼出来:
Q1: 什么是多态?
答 :多态就是同一个接口,不同的对象调用产生不同的行为。在 C++ 中,多态通过虚函数实现。当基类指针或引用指向派生类对象,并调用虚函数时,实际调用的是派生类中重写的版本------这就是运行时多态。
Q2: 什么是重载、重写(覆盖)、重定义(隐藏)?
| 概念 | 作用域 | 关键字 | 要求 |
|---|---|---|---|
| 重载(Overload) | 同一作用域 | 无 | 函数名相同,参数不同 |
| 重写(Override) | 基类/派生类 | virtual | 函数名、参数、返回值完全相同,必须是虚函数 |
| 重定义/隐藏(Hide) | 基类/派生类 | 无 | 派生类的函数名和基类相同就构成隐藏 |
Q3: 多态的实现原理?
答:虚函数表(vftable) + 虚函数表指针(vfptr)。
- 每个含有虚函数的类有一张虚函数表,存储虚函数地址;
- 每个对象的前几个字节存储指向该虚函数表的指针;
- 调用虚函数时,运行时从虚表中找到实际函数地址再调用。
Q4: inline 函数可以是虚函数吗?
答 :可以 。但如果一个 inline 函数被声明为虚函数,编译器会忽略其 inline 属性 ,这个函数就不再是内联的。因为虚函数需要放入虚函数表中(需要取地址),而 inline 函数是直接在调用处展开的,二者不能同时存在。
Q5: 静态成员函数可以是虚函数吗?
答 :不能。原因有二:
- 静态成员函数没有 this 指针,无法通过对象去访问虚函数表;
- 静态成员函数可以通过
类名::函数名直接调用,这种调用方式完全绕过了虚表机制。
Q6: 构造函数可以是虚函数吗?
答 :不能 。因为对象中的虚函数表指针是在构造函数初始化列表阶段才初始化的。如果构造函数是虚函数,那么在构造之前就需要通过虚表查找它------但此时虚表指针还未初始化,形成了"鸡生蛋、蛋生鸡"的悖论。
Q7: 析构函数可以是虚函数吗?
答 :可以,而且强烈建议将基类的析构函数定义为虚函数。
经典场景:
cpp
Base* p = new Derive();
delete p; // 如果 ~Base() 不是虚函数,则只会调用 ~Base(),造成资源泄漏!
如果基类析构函数是虚函数,delete p 时会从虚表中查找实际析构函数,正确调用 ~Derive() -> ~Base() 的完整析构链。
判断准则 :如果一个类被设计为基类(会被继承),那么它的析构函数就应该声明为虚函数。
Q8: 对象访问普通函数快还是虚函数更快?
答:分两种情况:
- 普通对象调用 (非指针/引用):一样快。因为编译器在编译期就能确定调用哪个函数,不涉及虚表查找。
- 指针/引用调用 :普通函数更快。因为构成多态时,运行时需要通过虚函数表间接查找函数地址,多了一次间接寻址的开销。
Q9: 虚函数表是什么阶段生成的,存在哪里?
答:
- 生成时机 :编译阶段就生成了;
- 存储位置 :一般情况下存在代码段(常量区/只读数据段)。
Q10: C++ 菱形继承的问题?虚继承的原理?
答:
- 菱形继承问题:数据冗余(同一份基类数据存多份)+ 二义性(编译器无法确定访问哪个基类的成员);
- 虚继承原理 :通过虚基表(virtual base table)存储偏移量,使得公共虚基类在最终派生类对象中只存在一份。访问虚基类成员时,通过虚基表指针查找偏移量来定位。
- 注意 :虚函数表(vftable)和虚基表(vbtable)是两个完全不同的概念,面试时不要混淆!
Q11: 什么是抽象类?抽象类的作用?
答:
- 抽象类 :包含纯虚函数 (
virtual void func() = 0;)的类。抽象类不能实例化。 - 作用 :
- 强制派生类重写虚函数------派生类必须实现纯虚函数才能被实例化;
- 体现接口继承关系 ------抽象类定义接口规范,派生类提供具体实现。这是面向对象设计中"只继承接口,不继承实现"的最佳实践。
六、总结与思考
本文核心知识图谱
C++ 继承与多态底层核心
|
+-- 菱形虚拟继承
| +-- 问题:数据冗余 + 二义性
| +-- 解决方案:virtual 继承
| +-- 核心机制:虚基表(存储偏移量)
| +-- 设计亮点:统一的虚基表访问方式
|
+-- 虚函数表(单继承 & 多继承)
| +-- 单继承:一张虚表,新增虚函数追加到末尾
| +-- 多继承:多张虚表,新虚函数放在先继承的基类虚表中
| +-- this 指针修正:多继承中通过 thunk 修正 this
|
+-- 反向迭代器适配器
| +-- 模式:适配器设计模式
| +-- 核心:operator* 访问前一个位置
| +-- 对称设计:rbegin()<->end(), rend()<->begin()
|
+-- 高频面试题(11题全覆盖)
+-- 虚函数 vs 内联 vs 静态
+-- 构造/析构与虚函数
+-- 虚表生成时机与存储位置
+-- 抽象类的设计意义
几点深度思考
-
"零开销抽象"的代价 :C++ 的设计哲学是"不为不用的特性付出代价"。虚继承和多态的额外开销(指针间接访问、内存占用)只在真正使用这些特性时才会产生。这是 C++ 相比 Java/C# 等语言的重要优势。
-
不要混淆虚函数表和虚基表 :两者虽然都以 "virtual" 为前缀,都涉及指针查表,但服务于完全不同的目的 ------前者服务于多态调用 (运行时确定函数地址),后者服务于虚基类成员访问(确定偏移量)。面试时这是必考点!
-
简单的设计才是好的设计 :菱形继承虽然 C++ 支持,但实际项目中应尽量避免 设计菱形继承体系。同样,反向迭代器的适配器模式告诉我们:用组合 + 委托 比用复杂继承更容易写出清晰、可维护的代码。
-
面试背后的考察点 :那些看似刁钻的面试题("构造函数可以是虚函数吗"),其实考察的是你对对象构造过程 、虚表初始化时机等底层机制的深入理解。死记硬背答案是不够的------理解"为什么"才是关键。
如果对你有帮助,欢迎点赞、收藏、关注三连支持!