💭 写在前面
虚表是编译器的实现,而非C++的语言标准。上一章我们学习了多态的概念,本章我们深入探讨一下多态的原理。文章开头先说虚表指针,观察编译器的查表行为。首次观察我们先从监视窗口观察美化后的虚表 _vfptr,再透过内存窗口观察真实的 _vfptr。我们还会探讨为什么对象也能切片却不能实现多态的问题。对于虚表到底存在哪?我们会带着大家通过一些打印虚表的方式进行比对!铺垫完虚表的知识后,会讲解运行时决议与编译时决议,穿插动静态的知识点。文章的最后我们会探讨单继承与多继承的虚表,多继承中的虚表神奇的切片指针偏移问题,这块难度较大,后续我们会考虑专门讲解一下,顺带着把钻石虚拟继承给讲了。
Ⅰ. 虚函数表(VTBL)
0x00 引入:发现虚表的存在
❓ 我们首先来做一道题:sizeof(Base) 是多少(32位下)?
class Base { public: virtual void Func1() { cout << "Func1()" << endl; } private: int _b = 1; }; int main(void) { Base b; return 0; }
💡 答案:答案令人诧异,居然是 8。
通过监视窗口我们发现除了 _b 成员外还有了一个 _vfptr 在 b1 对象中:
不监视不知道,一监视吓一跳。这个 _vfptr 是个什么 √8 玩意?
对象中的这个 _vfptr 我们称之为虚表指针(virtual function pointer),我们简称其为 虚表 。
一个含有虚函数的类中都至少有一个像这样的虚函数表指针,虚函数地址都会放到这个表里。
那么虚函数表中放了些什么呢?我们继续往下看。
💬 为了方便演示,我们再多整点函数:
class Base { public: void Func1() { cout << "Func1()" << endl; } virtual void Func2() { cout << "Func2()" << endl; } virtual void Func3() { cout << "Func3()" << endl;; } private: int _b = 1; };
通过监视窗口我们可以看到,虚函数 Func2 和 Func3 都被存进了 _vfptr 中。
虚表虚表,自然是存虚函数的表了,Func1 不是虚函数,自然也就不会存入表中。
0x01 观察虚表指针 _vfptr
❓ 思考:多态是怎么做到指向哪就调用哪的?对于父类的虚表又是什么样的呢?
💬 代码:我们用的是 VS2013 + 64位 环境去观测:
class Base { public: virtual void Func1() { cout << "Base::Func1()" << endl; } virtual void Func2() { cout << "Base::Func2()" << endl; } void Func3() { cout << "Base::Func3()" << endl; } private: int _b = 1; }; int main(void) { cout << sizeof(Base) << endl; Base b; return 0; }
🔍 监视:我们还是先用监视窗口去做一个简单的观察:
监视窗口是为了方便我们观测优化过的,相当于是一种美化。
注意看,Func3 没有放在 _vfptr 中,又一次证明了这个表里只会存虚函数。
其实虚函数表也没搞什么特殊,也没什么了不起的,虚函数其实是和普通函数一样存在代码段的。
只是普通函数只会进符号表以方便链接,都是 "编译时决议",
而虚函数的地址会被放进虚表,是为了 "运行时决议" 做准备,这个我们后面会细说。
所以这里我们可以这么理解:
📚 虚表的本质:虚表是一个 "存虚函数指针的指针数组" ,一般情况这个数组最后面会放一个空指针。
0x02 虚函数的重写与覆盖
回忆一下,上一章我们介绍重写的时候还说过,"重写" 还可以称为 "覆盖",
这是为什么呢?叫重写似乎更好去理解,覆盖好像很难去理解啊。
💬 代码:现在我们增加一个子类 Derive 去继承 Base:
// 父类 Base class Base { public: virtual void Func1() { cout << "Base::Func1()" << endl; } virtual void Func2() { cout << "Base::Func2()" << endl; } void Func3() { cout << "Base::Func3()" << endl; } private: int _b = 1; }; // 子类 Derive class Derive : public Base { public: virtual void Func1() { cout << "Derive::Func1()" << endl; } private: int _d = 2; }; int main(void) { cout << sizeof(Derive) << endl; Derive d; return 0; }
🚩 运行结果:
(如果没有虚表这里会是8)
🔍 监视:我们再通过监视窗口观察
和父类的相对比,冷静分析后不难发现:
父类 b 对象和子类 d 对象虚表是不一样的,这里看我们发现 Func1 完成了重写,
所以 d 的虚表中存的是重写的 Derive::Func1,所以虚函数的重写也叫做覆盖。
就可以理解为:子类的虚表拷贝了父类的虚表,子类的 Func1 覆盖掉了父类上的 Func1。
(覆盖指的是虚表中虚函数的覆盖)
虚函数重写:语法层的概念,子类对继承父类虚函数实现进行了重写。
虚函数覆盖:原理层的概念,子类的虚表,拷贝父类虚表进行了修改,覆盖重写那个虚函数。
🔺 总结:虚函数的重写与覆盖,重写是语法层的叫法,覆盖是原理层的叫法。
0x03 编译器的查表行为
❓ 思考: 是如何做到指针指向谁就调用谁的虚函数的?好像非常的听♂话:
class Base { public: virtual void Func1() { cout << "Base::Func1()" << endl; } virtual void Func2() { cout << "Base::Func2()" << endl; } void Func3() { cout << "Base::Func3()" << endl; } private: int _b = 1; }; class Derive : public Base { public: virtual void Func1() { cout << "Derive::Func1()" << endl; } private: int _d = 2; }; int main(void) { Base b; Derive d; Base* ptr = &b; ptr->Func1(); // 调用的是父类的虚函数 ptr = &d; ptr->Func1(); // 调用的是子类的虚函数 return 0; }
🚩 运行结果:
能不能猜到是跟虚表有关系?它到底要调用哪个函数不是按类型去定的,
如果是按类型去定的那这里调的应该都是父类,结果会都是 Base::Func1() ,所以显然不是。
这里会去 ptr 指针指向的对象里去查表,其实对它自己而言它自己都也不知道调用的是谁,
因为子类切个片,它自己也只能看到父类对象,它根本就没法知道,但是他会查表!
📚 具体行为如下:
编译器会从指向的对象里去找,先在父类对象里找到了 Base::Func1,
Base* ptr = &b; // 指向是b,是父类Base的 ptr->Func1(); // 调用的是父类的虚函数
然后指向变为 &d,它就从子类对象里找,从而找到了 Derive::Func1。
ptr = &d; // 指向变成d了,是子类Derive的 ptr->Func1(); // 这时调用的就是子类的虚函数了
所以,多态调用实现是依靠运行时去指向对象的虚表中查,调用函数地址。
0x04 探讨:对象也能切片,为什么不能实现多态?
既然指针和引用可以实现多态,那父类赋值给子类对象也可以切片,
为什么实现不了多态?搞歧视?
Base* ptr = &d; ✅ Base& ref = d; ✅ Base b = d; ❓ 为什么不行?都是支持切片的,为什么对象就不行?
从编译器的角度,编译器实现时会判断构不构成多态,不满足规则不构成多态就找到地址,call。
至于为什么实现不了多态,因为实现出来会出现混乱状态。
"即使你是一门语言的设计者,遇到这种问题也很难解决 "
根本原因是:对象切片时,子类对象只会拷贝成员给父类对象,并不会拷贝虚表指针。
因为拷贝了就混乱了,父类对象中到底是父类的虚表指针?还是子类的虚表指针?
那下面的调用是调用父类的虚函数还是子类的虚函数?就不确定了:
ptr = &b; ptr->func1(); // ?????????? 父类的func1,还是子类的func1?
对象实现多态又不得不去拷贝虚表,因为它肯定是需要去对象里的虚表里找,
问题是拷贝虚表后就乱了套了。最大问题是 —— 这时候到底调用的是谁的问题。
如果一个父类对象切片拷贝给子类后,切片前指向子类,没切片前指向父类。
"这让人头大"
所以对象不能实现多态,想实现也不行,实现了就乱了套了!
🔺 总结:
一个类对象中的 __vptr 是恒定的,它永远都会指向其所属类的虚表。
而当使用指针或引用时,__vptr 会指向继承类的虚表(从而达成多态的效果)
0x05 透过内存窗口仔细观察 _vfptr
💬 打开监视窗口观察下列代码的虚表:
class Base { public: virtual void Func1() { cout << "Base::Func1()" << endl; } virtual void Func2() { cout << "Base::Func2()" << endl; } void Func3() { cout << "Base::Func3()" << endl; } private: int _b = 1; }; class Derive : public Base { public: virtual void Func1() { cout << "Derive::Func1()" << endl; } void Func3() { cout << "Derive::Func3()" << endl; } virtual void Func4() { cout << "Derive::Func4()" << endl; } private: int _d = 2; };
从监视窗口观察,有时候会发现,好像有些虚函数再监视窗口显示的虚表里不存在。
这时候千万不能动摇 "只要是虚函数都会存入虚表" 这个事实。
这是监视窗口的锅,我们前面就说了 —— 监视窗口是美化过的!
想要看到真实的样子,我们可以打开内存去查看:
但是这内存看的很让人迷糊,这谁看得懂,知道谁是谁?有什么办法可以把虚表打印出来?
💬 只要取到虚表指针,想打印虚表就很简单了:
虚表是个函数指针数组,该数组里的每个元素存放的是一个函数指针。
typedef void(*V_FUNC)(); /* 打印虚表 */ void Print_VFTable(V_FUNC* arr) { printf("vfptr:%p\n", arr); for (size_t i = 0; arr[i] != nullptr; i++) { printf("[%d]: %p\n", i, arr[i]); V_FUNC foo = arr[i]; foo(); } } int main(void) { Derive d; Print_VFTable( (V_FUNC*)(*((int*)&d)) // 指针之间是可以互相转换的 ); /* 语法有规定:完全没有关系的类型强转也不行。 至少得有一点关系:比如指针和int 因为指针虽然是地址但是也是表示地址的编号,第几个编号的地址 指针之间可以随意转换,我想取4个字节,&d 是个 Derive*, 接引用后就是 Derive,此时强转成 int* 解引用就是取4个字节了。 由于是 int* 要调用 Print_VFTable 函数传不过去,所以我们最外层又 强转回 V_FUNC* 了,这是一个函数指针数组的地址指针。 “内线转外线再转内线” */ return 0; }
🚩 运行结果:
🔺 结论:VS 监视窗口看到的虚函数表不一定是真实的,可能被处理过。
0x06 虚表的存储位置
虚表,一个类型共用一个类型虚表吗?虚表到底存在哪?
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; }
🚩 运行结果:
🔺 结论:同一个类型它们的虚表内存地址都是一样的,同一类型的对象共用一份虚表。
现在我们知道了同一类型的对象公用一张虚表了,我们再来思考虚表存在哪里的问题。
❓ 思考:虚表到底存在哪里?
虚表放在栈上合理吗?显然不合理,放在栈上虚表跟着这个对象走跟着那个对象走,太不稳定了。
虚表最好能够永久存储,我们希望虚表稳稳地存着。
我们说的对象在构造的时候初始化虚表,实际上不是建立虚表,
按理来说编译的时候就已经把虚表建立好了,会在构造函数的初始化列表阶段把地址存进虚表。
此外,不仅要将虚表放置到永久的区域,不能因为某个对象销毁了这个虚表就没了,
那其他对象住哪?他们可是要共用同一张虚表的!!!所以这个虚表要保证一直都在。
并且还要很容易就能找到,那存在堆上可以吗?
不太行!堆要动态申请,虽然让第一个实例化的对象申请似乎也是可以的,但是堆释放啊!
谁去释放?让最后一个走的对象释放?那不还得加引用计数,所以虚表放堆上也不太可能。
那现在栈也不能存,堆也不能存,就只剩下常量区和数据段了。
静态区和常量区存放好像也很合理,当你实在不确定它到底在哪里的时,
这时候就需要一种 "验证问题的逆向精神",就比如刚才打印虚表指针,正是这种精神。
当然,这很依赖丰富的基础知识,是需要大量练习和实际锻炼的。
💬 比对:
int c = 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 = 0; static int b = 1; // 静态区 const char* str = "Hello,World!\n"; // str在栈上,但指向的空间在常量区 int* p = new int[10]; // p在栈上,但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("函数:%p\n", Derive::Func3); printf("函数:%p\n", Derive::Func2); printf("函数:%p\n", Derive::Func1); return 0; }
🚩 运行结果:
最合适的地方似乎就是数据段了。
想一想一下虚表是什么,是一个函数指针数组,放到数据段上是再合适不过的了。
🔺 总结:虚表存储在数据段上。