从C语言到C++_23(多态)抽象类+虚函数表VTBL+多态的面试题(上):https://developer.aliyun.com/article/1521912
2.3 接口继承和实现继承
纯虚函数也是可以实现的,但是,纯虚函数的实现没有什么太大意义,因为根本就没人能用它。
你实现一个东西是为了让人能调用你,纯虚函数谁能调用?根本没有人能调用它。
所以纯虚函数一般给个声明就可以了,它本身就是一个接口继承。
普通函数的继承是一种实现继承,子类继承了父类函数,可以使用函数,继承的是函数的实现。
虚函数的继承是一种接口继承,子类继承的是父类虚函数的接口,目的是为了重写,
达成多态,继承的是接口。所以如果不实现多态,不要把函数定义成虚函数。
出现虚函数就是为了提醒你重写的,以实现多态。如果虚函数不重写,那写成虚函数就没价值了。
3. 虚函数表(VTBL)
我们首先来做一道题:sizeof(Base) 是多少(32位下)?
#include <iostream> using namespace std; class Base { public: virtual void Func1() { cout << "Func1()" << endl; } virtual void Func2() { cout << "Func2()" << endl; } protected: int _b = 0; }; int main() { Base b; cout << sizeof(b) << endl; return 0; }
居然是 8。
通过监视窗口我们发现除了 _b 成员外还有了一个 _vfptr 在 b1 对象中:
对象中的这个 _vfptr 我们称之为虚表指针(virtual function pointer),我们简称其为 虚表 。
一个含有虚函数的类中都至少有一个像这样的虚函数表指针,虚函数地址都会放到这个表里。
虚函数表是一个函数指针数组,虚函数表存储在数据段上(常量区)。
那么虚函数表中放了些什么呢?我们继续往下看。
3.1 观察虚表指针 __vfptr
同类型的对象用的是同一个虚表:
#include <iostream> using namespace std; class Base { public: virtual void Func1() { cout << "Func1()" << endl; } virtual void Func2() { cout << "Func2()" << endl; } protected: int _b = 0; }; int main() { Base b1; Base b2; return 0; }
我们增加一个派生类Derive去继承Base
Base再增加一个虚函数Func2和一个普通函数Func3
#include <iostream> using namespace std; class Base { public: virtual void Func1() { cout << "Func1()" << endl; } virtual void Func2() { cout << "Func2()" << endl; } void Func3() { cout << "Func3()" << endl; } protected: int _b = 0; }; class Derive : public Base { public: virtual void Func1() { cout << "Derive::Func1()" << endl; } protected: int _d = 1; }; int main() { Base b; cout << sizeof(b) << endl; return 0; }
监视窗口是为了方便我们观测优化过的,相当于是一种美化。
Func3 没有放在 _vfptr 中,证明了这个表里只会存虚函数。
其实虚函数表也没搞什么特殊,也没什么了不起的,虚函数其实是和普通函数一样存在代码段的。
只是普通函数只会进符号表以方便链接,都是 "编译时决议",
而虚函数的地址会被放进虚表,是为了 "运行时决议" 做准备,这个我们后面会细说。
虚表的本质:虚表是一个 "存虚函数指针的指针数组" ,(函数指针数组)
一般情况这个数组最后面会放一个空指针,(取决于编译器)。
3.2 虚函数的重写与覆盖
介绍重写的时候还说过,"重写" 还可以称为 "覆盖",
这是为什么呢?叫重写似乎更好去理解,覆盖好像很难去理解啊。
#include <iostream> using namespace std; class Base { public: virtual void Func1() { cout << "Func1()" << endl; } virtual void Func2() { cout << "Func2()" << endl; } void Func3() { cout << "Func3()" << endl; } protected: int _b = 0; }; class Derive : public Base { public: virtual void Func1() { cout << "Derive::Func1()" << endl; } protected: int _d = 1; }; int main() { Base b; cout << sizeof(b) << endl; Derive d; cout << sizeof(Derive) << endl; return 0; }
父类 b 对象和子类 d 对象虚表是不一样的,这里看我们发现 Func1 完成了重写,
所以 d 的虚表中存的是重写的 Derive::Func1,所以虚函数的重写也叫做覆盖。
就可以理解为:子类的虚表拷贝了父类的虚表,子类的 Func1 覆盖掉了父类上的 Func1。
(覆盖指的是虚表中虚函数的覆盖)
- 虚函数重写:语法层的概念,子类对继承父类虚函数实现进行了重写。
- 虚函数覆盖:原理层的概念,子类的虚表,拷贝父类虚表进行了修改,覆盖重写那个虚函数。
总结:虚函数的重写与覆盖,重写是语法层的叫法,覆盖是原理层的叫法。
3.3 编译器的查表行为
编译器是如何做到指针指向谁就调用谁的虚函数的?
#include <iostream> using namespace std; 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 = 0; }; class Derive : public Base { public: virtual void Func1() { cout << "Derive::Func1()" << endl; } private: int _d = 1; }; int main() { Base b; Derive d; Base* ptr = &b; ptr->Func1(); // 调用的是父类的虚函数 ptr = &d; ptr->Func1(); // 调用的是子类的虚函数 return 0; }
能不能猜到是跟虚表有关系?它到底要调用哪个函数不是按类型去定的,
如果是按类型去定的那这里调的应该都是父类,结果会都是 Base::Func1() ,所以显然不是。
这里会去 ptr 指针指向的对象里去查表,其实对它自己而言它自己都也不知道调用的是谁,
因为子类切个片,它自己也只能看到父类对象,它根本就没法知道,但是他会查表。
编译器会从指向的对象里去找,先在父类对象里找到了 Base::Func1,
然后指向变为 &d,它就从子类对象里找,从而找到了 Derive::Func1。
所以,多态调用实现是依靠运行时去指向对象的虚表中查,调用函数地址。
对象也能切片,为什么不能实现多态?
既然指针和引用可以实现多态,那父类赋值给子类对象也可以切片,
为什么实现不了多态?
Base* ptr = &d; √
Base& ref = d; √
Base b = d; × 为什么不行?都是支持切片的,为什么对象就不行?
从编译器的角度,编译器实现时会判断构不构成多态,不满足规则不构成多态就找到地址,call。
至于为什么实现不了多态,因为实现出来会出现混乱状态。
"即使你是一门语言的设计者,遇到这种问题也很难解决 "
根本原因是:对象切片时,子类对象只会拷贝成员给父类对象,并不会拷贝虚表指针。
因为拷贝了就混乱了,父类对象中到底是父类的虚表指针?还是子类的虚表指针?
那下面的调用是调用父类的虚函数还是子类的虚函数?就不确定了:
ptr = &b;
ptr->func1(); // ?????????? 父类的func1,还是子类的func1?
对象实现多态又不得不去拷贝虚表,因为它肯定是需要去对象里的虚表里找,
问题是拷贝虚表后就乱了套了。最大问题是 —— 这时候到底调用的是谁的问题。
如果一个父类对象切片拷贝给子类后,切片前指向子类,没切片前指向父类。"这让人头大"
所以对象不能实现多态,想实现也不行,实现了就乱套了。
总结:
一个类对象中的 __vptr 是恒定的,它永远都会指向其所属类的虚表。
当使用指针或引用时,__vptr 会指向继承类的虚表(从而达成多态的效果)
3.4 通过打印观察 __vfptr
打开监视窗口观察下列代码的虚表:
#include <iostream> using namespace std; 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 = 0; }; 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 = 1; }; int main() { Base b; Derive d; return 0; }
从监视窗口观察,有时候会发现,好像有些虚函数再监视窗口显示的虚表里不存在。
这时候千万不能动摇 "只要是虚函数都会存入虚表" 这个事实。
这是监视窗口的锅,前面就过说了:监视窗口是美化过的。
想要看到真实的样子,我们可以打开内存去查看,
但是内存很难看懂,有什么办法可以把虚表打印出来?
虚表是个函数指针数组,该数组里的每个元素存放的是一个函数指针。
只要取到虚表指针,想打印虚表就很简单了:
#include <iostream> using namespace std; 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 = 0; }; 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 = 1; }; typedef void(*V_FUNC)(); // 把函数指针 void(*)() typedef 成 V_FUNC void Print_VFTable(V_FUNC* arr) { printf("vfptr:%p\n", arr); for (size_t i = 0; arr[i] != nullptr; ++i) // 如果编译器在最后无nullptr,就要手动传参,有几个就打印几个 { printf("vfptr[%d]: %p\n", i, arr[i]); V_FUNC Func = arr[i]; Func(); // 函数指针加括号->调用对应的函数 } } int main() { Derive d; Print_VFTable ( (V_FUNC*)(*((int*)&d))// 取d对象的头四个字节 指针之间是可以互相转换的 ); // 把d取地址强转成int*,解引用就取出头四个字节(int),最后强转成V_FUNC* 传参 // 语法有规定:完全没有关系的类型强转也不行。 // 至少得有一点关系:比如指针和int // 因为指针虽然是地址但是也是表示地址的编号,第几个编号的地址 // 指针之间可以随意转换,我想取4个字节,& d 是个 Derive* , // 接引用后就是 Derive,此时强转成 int* 解引用就是取4个字节了。 // 由于是 int* 要调用 Print_VFTable 函数传不过去,所以我们最外层又 // 强转回 V_FUNC* 了,这是一个函数指针数组的地址指针。 // “内线转外线再转内线” return 0; }
VS下打印父类出错了(这是VS的BUG,在release下就正常),
手动传试一下,Linux下也是这样手动传:
#include <iostream> using namespace std; 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 = 0; }; 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 = 1; }; typedef void(*V_FUNC)(); // 把函数指针 void(*)() typedef 成 V_FUNC void Print_VFTable(V_FUNC* arr,size_t n) { printf("vfptr:%p\n", arr); for (size_t i = 0; /*arr[i] != nullptr*/ i < n; ++i) // 如果编译器在最后无nullptr,就要手动传参,有几个就打印几个 { printf("vfptr[%d]: %p\n", i, arr[i]); V_FUNC Func = arr[i]; Func(); // 函数指针加括号->调用对应的函数 } } int main() { Derive d; Print_VFTable ( (V_FUNC*)(*((int*)&d)),3// 取d对象的头四个字节 指针之间是可以互相转换的 ); // 把d取地址强转成int*,解引用就取出头四个字节(int),最后强转成V_FUNC* 传参 // 语法有规定:完全没有关系的类型强转也不行。 // 至少得有一点关系:比如指针和int // 因为指针虽然是地址但是也是表示地址的编号,第几个编号的地址 // 指针之间可以随意转换,我想取4个字节,& d 是个 Derive* , // 接引用后就是 Derive,此时强转成 int* 解引用就是取4个字节了。 // 由于是 int* 要调用 Print_VFTable 函数传不过去,所以我们最外层又 // 强转回 V_FUNC* 了,这是一个函数指针数组的地址指针。 // “内线转外线再转内线” Base b; cout << endl; Print_VFTable ( (V_FUNC*)(*((int*)&b)),2 ); return 0; }
结论:VS 监视窗口看到的虚函数表不一定是真实的,可能被处理过。
3.5 运行时决议与编译时决议
刚才知道了,多态调用实现是靠运行时查表做到的,再看一段代码。
注意 Func3 不是虚函数:
#include <iostream> using namespace std; 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 = 0; }; class Derive : public Base { public: virtual void Func1() { cout << "Derive::Func1()" << endl; } private: int _d = 1; }; int main() { Base b; Derive d; Base* ptr = &b; ptr->Func1(); // 调用的是父类的虚函数 ptr->Func3(); ptr = &d; ptr->Func1(); // 调用的是子类的虚函数 ptr->Func3(); return 0; }
这里 Func3 为什么不是 Derive 的?因为 Func3 不是虚函数,它没有进入虚表。
如果从更深的角度 —— 汇编层面去看,就可以牵扯出编译时决议和运行时决议。
决议的意思就是如何去确定函数的地址,一个是在运行时确定,一个是在编译时确定。
多态调用:运行时决议,即运行时确定调用函数的地址。【通过查虚函数表】
(编译完后通过指令,去对象中虚表里去找虚函数运行,是运行时去找,找到了才调用)
(这正是多态底层实现的原理,编译器去检查,
如果满足多态的条件了,它就按运行时决议的方式。)
普通调用:编译时决议,编译时确定调用函数的地址。【通过类型】
(所有的编译时确定都是看 ptr 是什么类型,跟对象没有关系,不看指向的对象,
自己是什么类型,就去哪里找 Func1)
3.6 动态绑定与静态绑定
静态库:指的是链接的那个阶段链接的库。
动态库:程序运行起来后才加载,去动态库里找。
静态绑定:又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,
也称为静态多态。比如函数重载。
动态绑定:又称后期绑定(晚绑定),在程序运行期间,
根据具体拿到的类型确定程序的具体行为,调用具体的函数,也成为动态多态。
多态在有些书上还细分了静态的多态和动态的多态。
静态的多态(编译时):指的是函数重载。
动态的多态(运行时):指的是本节内容讲的这个。
从C语言到C++_23(多态)抽象类+虚函数表VTBL+多态的面试题(下):https://developer.aliyun.com/article/1521918?spm=a2c6h.13148508.setting.17.712b4f0euyAwd3