C++ 表情包趣味教程 👉 《C++要笑着学》
💭 写在前面
虚表是编译器的实现,而非C++的语言标准。上一章我们学习了多态的概念,本章我们深入探讨一下多态的原理。文章开头先说虚表指针,观察编译器的查表行为。首次观察我们先从监视窗口观察美化后的虚表 _vfptr,再透过内存窗口观察真实的 _vfptr。我们还会探讨为什么对象也能切片却不能实现多态的问题。对于虚表到底存在哪?我们会带着大家通过一些打印虚表的方式进行比对!铺垫完虚表的知识后,会讲解运行时决议与编译时决议,穿插动静态的知识点。文章的最后我们会探讨单继承与多继承的虚表,多继承中的虚表神奇的切片指针偏移问题,这块难度较大,后续我们会考虑专门讲解一下,顺带着把钻石虚拟继承给讲了。
本篇博客全站热榜排名:未上榜
❓ 我们首先来做一道题:sizeof(Base) 是多少(32位下)?
class Base {
public:virtual void Func1() {cout <<"Func1()" <
};int main(void)
{Base b;return 0;
}
&#x1f4a1; 答案&#xff1a;答案令人诧异&#xff0c;居然是 8。
通过监视窗口我们发现除了 _b 成员外还有了一个 _vfptr 在 b1 对象中&#xff1a;
不监视不知道&#xff0c;一监视吓一跳。这个 _vfptr 是个什么 √8 玩意&#xff1f;
对象中的这个 _vfptr 我们称之为虚表指针&#xff08;virtual function pointer&#xff09;&#xff0c;我们简称其为 虚表 。
一个含有虚函数的类中都至少有一个像这样的虚函数表指针&#xff0c;虚函数地址都会放到这个表里。
那么虚函数表中放了些什么呢&#xff1f;我们继续往下看。
&#x1f4ac; 为了方便演示&#xff0c;我们再多整点函数&#xff1a;
class Base {
public:void Func1() {cout <<"Func1()" <
};
通过监视窗口我们可以看到&#xff0c;虚函数 Func2 和 Func3 都被存进了 _vfptr 中。
虚表虚表&#xff0c;自然是存虚函数的表了&#xff0c;Func1 不是虚函数&#xff0c;自然也就不会存入表中。
❓ 思考&#xff1a;多态是怎么做到指向哪就调用哪的&#xff1f;对于父类的虚表又是什么样的呢&#xff1f;
&#x1f4ac; 代码&#xff1a;我们用的是 VS2013 &#43; 64位 环境去观测&#xff1a;
class Base {
public:virtual void Func1() {cout <<"Base::Func1()" <
};int main(void)
{cout <
&#x1f50d; 监视&#xff1a;我们还是先用监视窗口去做一个简单的观察&#xff1a;
监视窗口是为了方便我们观测优化过的&#xff0c;相当于是一种美化。
注意看&#xff0c;Func3 没有放在 _vfptr 中&#xff0c;又一次证明了这个表里只会存虚函数。
其实虚函数表也没搞什么特殊&#xff0c;也没什么了不起的&#xff0c;虚函数其实是和普通函数一样存在代码段的。
只是普通函数只会进符号表以方便链接&#xff0c;都是 "编译时决议"&#xff0c;
而虚函数的地址会被放进虚表&#xff0c;是为了 "运行时决议" 做准备&#xff0c;这个我们后面会细说。
所以这里我们可以这么理解&#xff1a;
&#x1f4da; 虚表的本质&#xff1a;虚表是一个 "存虚函数指针的指针数组" &#xff0c;一般情况这个数组最后面会放一个空指针。
回忆一下&#xff0c;上一章我们介绍重写的时候还说过&#xff0c;"重写" 还可以称为 "覆盖"&#xff0c;
这是为什么呢&#xff1f;叫重写似乎更好去理解&#xff0c;覆盖好像很难去理解啊。
&#x1f4ac; 代码&#xff1a;现在我们增加一个子类 Derive 去继承 Base&#xff1a;
// 父类 Base
class Base {
public:virtual void Func1() {cout <<"Base::Func1()" <
};// 子类 Derive
class Derive : public Base {
public:virtual void Func1() {cout <<"Derive::Func1()" <
};int main(void)
{cout <
&#x1f6a9; 运行结果&#xff1a;
&#xff08;如果没有虚表这里会是8&#xff09;
&#x1f50d; 监视&#xff1a;我们再通过监视窗口观察
和父类的相对比&#xff0c;冷静分析后不难发现&#xff1a;
父类 b 对象和子类 d 对象虚表是不一样的&#xff0c;这里看我们发现 Func1 完成了重写&#xff0c;
所以 d 的虚表中存的是重写的 Derive::Func1&#xff0c;所以虚函数的重写也叫做覆盖。
就可以理解为&#xff1a;子类的虚表拷贝了父类的虚表&#xff0c;子类的 Func1 覆盖掉了父类上的 Func1。
&#xff08;覆盖指的是虚表中虚函数的覆盖&#xff09;
&#x1f53a; 总结&#xff1a;虚函数的重写与覆盖&#xff0c;重写是语法层的叫法&#xff0c;覆盖是原理层的叫法。
❓ 思考&#xff1a; 是如何做到指针指向谁就调用谁的虚函数的&#xff1f;好像非常的听♂话&#xff1a;
class Base {
public:virtual void Func1() {cout <<"Base::Func1()" <
};class Derive : public Base {
public:virtual void Func1() {cout <<"Derive::Func1()" <
};int main(void)
{Base b;Derive d;Base* ptr &#61; &b; ptr->Func1(); // 调用的是父类的虚函数ptr &#61; &d;ptr->Func1(); // 调用的是子类的虚函数return 0;
}
&#x1f6a9; 运行结果&#xff1a;
能不能猜到是跟虚表有关系&#xff1f;它到底要调用哪个函数不是按类型去定的&#xff0c;
如果是按类型去定的那这里调的应该都是父类&#xff0c;结果会都是 Base::Func1() &#xff0c;所以显然不是。
这里会去 ptr 指针指向的对象里去查表&#xff0c;其实对它自己而言它自己都也不知道调用的是谁&#xff0c;
因为子类切个片&#xff0c;它自己也只能看到父类对象&#xff0c;它根本就没法知道&#xff0c;但是他会查表&#xff01;
&#x1f4da; 具体行为如下&#xff1a;
编译器会从指向的对象里去找&#xff0c;先在父类对象里找到了 Base::Func1&#xff0c;
Base* ptr &#61; &b; // 指向是b&#xff0c;是父类Base的ptr->Func1(); // 调用的是父类的虚函数
然后指向变为 &d&#xff0c;它就从子类对象里找&#xff0c;从而找到了 Derive::Func1。
ptr &#61; &d; // 指向变成d了&#xff0c;是子类Derive的
ptr->Func1(); // 这时调用的就是子类的虚函数了
所以&#xff0c;多态调用实现是依靠运行时去指向对象的虚表中查&#xff0c;调用函数地址。
既然指针和引用可以实现多态&#xff0c;那父类赋值给子类对象也可以切片&#xff0c;
为什么实现不了多态&#xff1f;搞歧视&#xff1f;
Base* ptr &#61; &d; ✅
Base& ref &#61; d; ✅ Base b &#61; d; ❓ 为什么不行&#xff1f;都是支持切片的&#xff0c;为什么对象就不行&#xff1f;
从编译器的角度&#xff0c;编译器实现时会判断构不构成多态&#xff0c;不满足规则不构成多态就找到地址&#xff0c;call。
至于为什么实现不了多态&#xff0c;因为实现出来会出现混乱状态。
"即使你是一门语言的设计者&#xff0c;遇到这种问题也很难解决 "
根本原因是&#xff1a;对象切片时&#xff0c;子类对象只会拷贝成员给父类对象&#xff0c;并不会拷贝虚表指针。
因为拷贝了就混乱了&#xff0c;父类对象中到底是父类的虚表指针&#xff1f;还是子类的虚表指针&#xff1f;
那下面的调用是调用父类的虚函数还是子类的虚函数&#xff1f;就不确定了&#xff1a;
ptr &#61; &b;
ptr->func1(); // ?????????? 父类的func1&#xff0c;还是子类的func1&#xff1f;
对象实现多态又不得不去拷贝虚表&#xff0c;因为它肯定是需要去对象里的虚表里找&#xff0c;
问题是拷贝虚表后就乱了套了。最大问题是 —— 这时候到底调用的是谁的问题。
如果一个父类对象切片拷贝给子类后&#xff0c;切片前指向子类&#xff0c;没切片前指向父类。
"这让人头大"
所以对象不能实现多态&#xff0c;想实现也不行&#xff0c;实现了就乱了套了&#xff01;
&#x1f53a; 总结&#xff1a;
&#x1f4ac; 打开监视窗口观察下列代码的虚表&#xff1a;
class Base {
public:virtual void Func1() {cout <<"Base::Func1()" <
};class Derive : public Base {
public:virtual void Func1() {cout <<"Derive::Func1()" <
};
从监视窗口观察&#xff0c;有时候会发现&#xff0c;好像有些虚函数再监视窗口显示的虚表里不存在。
这时候千万不能动摇 "只要是虚函数都会存入虚表" 这个事实。
这是监视窗口的锅&#xff0c;我们前面就说了 —— 监视窗口是美化过的&#xff01;
想要看到真实的样子&#xff0c;我们可以打开内存去查看&#xff1a;
但是这内存看的很让人迷糊&#xff0c;这谁看得懂&#xff0c;知道谁是谁&#xff1f;有什么办法可以把虚表打印出来&#xff1f;
&#x1f4ac; 只要取到虚表指针&#xff0c;想打印虚表就很简单了&#xff1a;
虚表是个函数指针数组&#xff0c;该数组里的每个元素存放的是一个函数指针。
typedef void(*V_FUNC)();/* 打印虚表 */
void Print_VFTable(V_FUNC* arr) {printf("vfptr:%p\n", arr);for (size_t i &#61; 0; arr[i] !&#61; nullptr; i&#43;&#43;) {printf("[%d]: %p\n", i, arr[i]);V_FUNC foo &#61; arr[i];foo();}
}int main(void)
{Derive d;Print_VFTable((V_FUNC*)(*((int*)&d)) // 指针之间是可以互相转换的);/* 语法有规定&#xff1a;完全没有关系的类型强转也不行。至少得有一点关系&#xff1a;比如指针和int因为指针虽然是地址但是也是表示地址的编号&#xff0c;第几个编号的地址指针之间可以随意转换&#xff0c;我想取4个字节&#xff0c;&d 是个 Derive*&#xff0c;接引用后就是 Derive&#xff0c;此时强转成 int* 解引用就是取4个字节了。由于是 int* 要调用 Print_VFTable 函数传不过去&#xff0c;所以我们最外层又强转回 V_FUNC* 了&#xff0c;这是一个函数指针数组的地址指针。“内线转外线再转内线”*/return 0;
}
&#x1f6a9; 运行结果&#xff1a;
&#x1f53a; 结论&#xff1a;VS 监视窗口看到的虚函数表不一定是真实的&#xff0c;可能被处理过。
虚表&#xff0c;一个类型共用一个类型虚表吗&#xff1f;虚表到底存在哪&#xff1f;
int main(void)
{Base b1;Base b2;Base b3;Base b4;Print_VFTable((V_FUNC*)(*((int*)&b1)));Print_VFTable((V_FUNC*)(*((int*)&b2)));Print_VFTable((V_FUNC*)(*((int*)&b3)));Print_VFTable((V_FUNC*)(*((int*)&b4)));return 0;
}
&#x1f6a9; 运行结果&#xff1a;
&#x1f53a; 结论&#xff1a;同一个类型它们的虚表内存地址都是一样的&#xff0c;同一类型的对象共用一份虚表。
现在我们知道了同一类型的对象公用一张虚表了&#xff0c;我们再来思考虚表存在哪里的问题。
❓ 思考&#xff1a;虚表到底存在哪里&#xff1f;
虚表放在栈上合理吗&#xff1f;显然不合理&#xff0c;放在栈上虚表跟着这个对象走跟着那个对象走&#xff0c;太不稳定了。
虚表最好能够永久存储&#xff0c;我们希望虚表稳稳地存着。
我们说的对象在构造的时候初始化虚表&#xff0c;实际上不是建立虚表&#xff0c;
按理来说编译的时候就已经把虚表建立好了&#xff0c;会在构造函数的初始化列表阶段把地址存进虚表。
此外&#xff0c;不仅要将虚表放置到永久的区域&#xff0c;不能因为某个对象销毁了这个虚表就没了&#xff0c;
那其他对象住哪&#xff1f;他们可是要共用同一张虚表的&#xff01;&#xff01;&#xff01;所以这个虚表要保证一直都在。
并且还要很容易就能找到&#xff0c;那存在堆上可以吗&#xff1f;
不太行&#xff01;堆要动态申请&#xff0c;虽然让第一个实例化的对象申请似乎也是可以的&#xff0c;但是堆释放啊&#xff01;
谁去释放&#xff1f;让最后一个走的对象释放&#xff1f;那不还得加引用计数&#xff0c;所以虚表放堆上也不太可能。
那现在栈也不能存&#xff0c;堆也不能存&#xff0c;就只剩下常量区和数据段了。
静态区和常量区存放好像也很合理&#xff0c;当你实在不确定它到底在哪里的时&#xff0c;
这时候就需要一种 "验证问题的逆向精神"&#xff0c;就比如刚才打印虚表指针&#xff0c;正是这种精神。
当然&#xff0c;这很依赖丰富的基础知识&#xff0c;是需要大量练习和实际锻炼的。
&#x1f4ac; 比对&#xff1a;
int c &#61; 2; // 全局变量
int main(void)
{Base b1;Base b2;Base b3;Base b4;Print_VFTable((V_FUNC*)(*((int*)&b1)));Print_VFTable((V_FUNC*)(*((int*)&b2)));Print_VFTable((V_FUNC*)(*((int*)&b3)));Print_VFTable((V_FUNC*)(*((int*)&b4)));int a &#61; 0;static int b &#61; 1; // 静态区const char* str &#61; "Hello,World!\n"; // str在栈上&#xff0c;但指向的空间在常量区int* p &#61; new int[10]; // p在栈上&#xff0c;但p指向的空间在堆上printf("栈: %p\n", &a);printf("静态区/数据段: %p\n", &b);printf("静态区/数据段: %p\n", &c);printf("常量区代码段: %p\n", str);printf("代码段: %p\n", str);printf("堆: %p\n", p);printf("虚表: %p\n", (*((int*)&b4)));printf("函数&#xff1a;%p\n", Derive::Func3);printf("函数&#xff1a;%p\n", Derive::Func2);printf("函数&#xff1a;%p\n", Derive::Func1);return 0;
}
&#x1f6a9; 运行结果&#xff1a;
最合适的地方似乎就是数据段了。
想一想一下虚表是什么&#xff0c;是一个函数指针数组&#xff0c;放到数据段上是再合适不过的了。
&#x1f53a; 总结&#xff1a;虚表存储在数据段上。
我们刚才知道了&#xff0c;多态调用实现是靠运行时查表做到的&#xff0c;我们再看一段代码。
&#x1f4ac; 在刚才代码基础上&#xff0c;让父类子类分别多调用一个 Func3&#xff0c;注意 Func3 不是虚函数&#xff1a;
class Base {
public:virtual void Func1() {cout <<"Base::Func1()" <
};class Derive : public Base {
public:virtual void Func1() {cout <<"Derive::Func1()" <
};int main(void)
{Base b;Derive d;Base* ptr &#61; &b; ptr->Func1(); // 调用的是父类的虚函数ptr->Func3();ptr &#61; &d;ptr->Func1(); // 调用的是子类的虚函数ptr->Func3();return 0;
}
&#x1f6a9; 运行结果&#xff1a;
❓ 问题&#xff1a;这里 Func3 为什么不是 Derive 的&#xff1f;
&#x1f4a1; 解答&#xff1a;因为 Func3 不是虚函数&#xff0c;它没有进入虚表。
如果我们从更深的角度 —— 汇编层面去看&#xff0c;就可以牵扯出编译时决议和运行时决议。
&#xff08;这个我们前面一直再提&#xff0c;我们现在就来好好讲讲~ 乖♂乖♂站♂好 &#xff09;
决议的意思就是如何去确定函数的地址&#xff0c;一个是在运行时确定&#xff0c;一个是在编译时确定。
&#x1f4da; 多态调用&#xff1a;运行时决议&#xff0c;即运行时确定调用函数的地址。【通过查虚函数表】
&#xff08;编译完后通过指令&#xff0c;去对象中虚表里去找虚函数运行&#xff0c;是运行时去找&#xff0c;找到了才调用&#xff09;
&#x1f4da; 普通调用&#xff1a;编译时决议&#xff0c;编译时确定调用函数的地址。【通过类型】
&#xff08;所有的编译时确定都是看 ptr 是什么类型&#xff0c;跟对象没有关系&#xff0c;不看指向的对象&#xff0c;自己是什么类型&#xff0c;就去哪里找 Func1&#xff09;
&#xff08;查看反汇编&#xff09;
这正是多态底层实现的原理&#xff0c;编译器去检查&#xff0c;如果满足多态的条件了&#xff0c;它就按运行时决议的方式。
静态库&#xff1a;指的是链接的那个阶段链接的库。
动态库&#xff1a;程序运行起来后才加载&#xff0c;去动态库里找。
静态绑定&#xff1a;又称为前期绑定&#xff08;早绑定&#xff09;&#xff0c;在程序编译期间确定了程序的行为&#xff0c;也称为静态多态。比如函数重载。
动态绑定&#xff1a;又称后期绑定&#xff08;晚绑定&#xff09;&#xff0c;在程序运行期间&#xff0c;根据具体拿到的类型确定程序的具体行为&#xff0c;调用具体的函数&#xff0c;也成为动态多态。
多态在有些书上还细分了静态的多态和动态的多态。
静态的多态&#xff08;编译时&#xff09;&#xff1a;指的是函数重载。
int x &#61; 0, y &#61; 1;
double a &#61; 0.0, b &#61; 1.1;swap(x, y);
swap(a, b);这两个 swap 让人感觉是同一个函数&#xff0c;
但实际不是。实际编译链接根据函数名修饰规则找到不同的函数。
动态的多态&#xff08;运行时&#xff09;&#xff1a;指的是本节内容讲的这个。
void Func(Person& p) {p.BuyTicket();
}Person Mike;
Func(Mike);Student Jack;
Func(Jack);
&#xff08;需要注意的是&#xff0c;在单继承和多继承关系中&#xff0c;下面我们去关注的是子类对象的虚表模型&#xff0c;因为父类的虚表模型我们前面已经看过了&#xff0c;没什么需要特别研究的地方&#xff09;
&#x1f4ac; 代码&#xff1a;单继承中的虚函数表&#xff1a;
class Base {
public:virtual void func1() { cout <<"Base::func1" <
};class Derive :public Base {
public:virtual void func1() { cout <<"Derive::func1" <
};
我们还是用刚才介绍的方法打印虚表&#xff1a;
typedef void(*VFPTR) ();
void PrintVTable(VFPTR vTable[]) {// 依次取虚表中的虚函数指针打印并调用&#xff0c;调用就可以看出存的是哪个函数cout <<" 虚表地址>" <
{Base b;Derive d;
}
&#x1f4ac; 代码&#xff1a;我们在把虚函数表打印出来看看&#xff08;32位取头上4个字节&#xff0c;64位需要取头上8个字节&#xff09;&#xff1a;
int main()
{Base b;Derive d;PrintVTable((VFPTR*)(*(int*)&d));return 0;
}
&#x1f6a9; 运行结果&#xff1a;
刚才我们看的是单继承&#xff0c;我们现在再看复杂一点的多继承。
&#x1f4ac; 代码&#xff1a;Base1 和 Base2 都进行了重写
class Base1 {
public:virtual void func1() { cout <<"Base1::func1" <
};class Base2 {
public:virtual void func1() { cout <<"Base2::func1" <
};class Derive : public Base1, public Base2 {
public:virtual void func1() { cout <<"Derive::func1" <
};typedef void(*VFPTR) ();
void PrintVTable(VFPTR vTable[]) {cout <<" 虚表地址>" <
{Derive d;VFPTR* vTableb1 &#61; (VFPTR*)(*(int*)&d);PrintVTable(vTableb1);VFPTR* vTableb2 &#61; (VFPTR*)(*(int*)((char*)&d &#43; sizeof(Base1)));PrintVTable(vTableb2);return 0;
}
这里 Derive 明显会有两张虚表&#xff0c;我们先透过监视简单看一下&#xff1a;
我们的 func3 是放哪一个虚表里&#xff1f;是两张都放一份&#xff0c;还是选择一份放呢&#xff1f;
func1 的两个地址好像不一样&#xff0c;0X0911ae 和 0X901249&#xff0c;因为它们都不是真正的函数的地址。
我们来看看 Derive 中的 func1 真正的地址&#xff1a;
printf("%p\n", &Derive::func1);
这里可能就是多套了一层&#xff0c;是一种保护机制。虽然不一样但是最后都跳到了函数上面去。
&#x1f53a; 结论&#xff1a;Derive 对象 Base2 虚表中 func1 时&#xff0c;是 Base2 指针 ptr2 取调用&#xff0c;但是这时 ptr2 发生切片指针偏移&#xff0c;需要修正。中途就需要修正存储 this 指针 ecx 的值。
❓ 问题&#xff1a;这里还有一个指针偏移的问题&#xff0c;在多继承中这三个指针的值是一样的吗&#xff1f;
&#x1f6a9; 运行结果&#xff1a;0073FBA0 0073FBA8 0073FBA0 &#x1f4a1; 答案&#xff1a;不一样。给人第一感觉好像是一样的&#xff0c;因为赋过去的值都是 &d&#xff0c;但实际上并不一样。 因为这里要发生切片&#xff0c;切片后赋值兼容&#xff0c;所以它们的地址就不一样了。 1. 什么是多态&#xff1f; 2. 什么是重载、重写(覆盖)、重定义(隐藏)&#xff1f; 3. 多态的实现原理&#xff1f;答&#xff1a;参考本节课件内容 4. inline函数可以是虚函数吗&#xff1f;答&#xff1a;可以&#xff0c;不过编译器就忽略inline属性&#xff0c;这个函数就不再是inline&#xff0c;因为 虚函数要放到虚表中去。 5. 静态成员可以是虚函数吗&#xff1f;答&#xff1a;不能&#xff0c;因为静态成员函数没有this指针&#xff0c;使用类型::成员函数的调用方式 无法访问虚函数表&#xff0c;所以静态成员函数无法放进虚函数表。 6. 构造函数可以是虚函数吗&#xff1f;答&#xff1a;不能&#xff0c;因为对象中的虚函数表指针是在构造函数初始化列表阶段才初始化的。 7. 析构函数可以是虚函数吗&#xff1f;什么场景下析构函数是虚函数&#xff1f;答&#xff1a;可以&#xff0c;并且最好把基类的析构函数定义 成虚函数。参考本节课件内容 8. 对象访问普通函数快还是虚函数更快&#xff1f;答&#xff1a;首先如果是普通对象&#xff0c;是一样快的。如果是指针对象或者是 引用对象&#xff0c;则调用的普通函数快&#xff0c;因为构成多态&#xff0c;运行时调用虚函数需要到虚函数表中去查找。 9. 虚函数表是在什么阶段生成的&#xff0c;存在哪的&#xff1f;答&#xff1a;虚函数表是在编译阶段就生成的&#xff0c;一般情况下存在代码 段(常量区)的。 10. C&#43;&#43;菱形继承的问题&#xff1f;虚继承的原理&#xff1f; &#x1f4dc; 参考资料 C&#43;&#43;reference[EB/OL]. []. http://www.cplusplus.com/reference/. Microsoft. MSDN(Microsoft Developer Network)[EB/OL]. []. . 百度百科[EB/OL]. []. https://baike.baidu.com/. 比特科技. C&#43;&#43;[EB/OL]. 2021[2021.8.31]. Base1* ptr1 &#61; &d;
Base2* ptr2 &#61; &d;
Derive* ptr3 &#61; &d;cout <
0x02 多态的一些题目
&#x1f4cc; [ 笔者 ] 王亦优
&#x1f4c3; [ 更新 ] 2022.9.5
❌ [ 勘误 ] /* 暂无 */
&#x1f4dc; [ 声明 ] 由于作者水平有限&#xff0c;本文有错误和不准确之处在所难免&#xff0c;本人也很想知道这些错误&#xff0c;恳望读者批评指正&#xff01;