【C++】面向对象编程的三大特性:深入解析多态机制(一)https://developer.aliyun.com/article/1617394
七、多态的原理(重点)
7.1 虚函数表
场景引入:计算sizeof(Base)大小
class Base { public: virtual void Func1() { cout << "Func1()" << endl; } private: int _b = 1; }; 结果:sizeof(Base) == 8
具体解析:
- 通过调式窗口,我们发现除了_b成员,还多了一个 _vfptr放在对象的前面。该指针称为虚函数表指针(v代表virtual,f代表function)。
- 一个含有虚函数的类都至少都有一个虚函数表指针,因为虚函数地址要被放到虚函数表中的虚函数表中,虚函数表也简称虚表。
- 在调试窗口中_vfptr位置跟平台有关系,有些平台可能会放置到对象的最后面。
7.2 派生类继承基类成员
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: //虚函数func1的重写 virtual void Func1() { cout << "Derive::Func1()" << endl; } private: int _d = 2; }; int main() { Base b; Derive d; return 0; }
通过观察和调式:
- 派生类对象d也存在虚表指针,d对象由两部分构成:基类继承下来的成员和虚表指针,另外一部分是自己的成员。
- 基类b对象和派生类d对象虚表是不一样的,这里Func1完成了重写,所以d的虚表中存的是重写的Derive :: Func1
- 所以虚函数的重写也叫作覆盖,覆盖就是指虚表中虚函数的覆盖。(重写是语法的叫法,覆盖是原理层的叫法)
- 只有虚函数才会被放到虚表里面。Func2继承下来后是虚函数,而Func3也继承下来了,但是不是虚函数,所以不会放进虚表
- 派生类没有自己的虚表指针,直接继承基类的虚表指针,如果无法继承,那么派生类自己建立虚表
- 虚函数表本质是一个虚函数指针的指针数组,一般情况这个数组最后后面放一个nullptr
总结派生类的虚表生成:
- 先将基类中的虚表内容拷贝一份到派生类虚表中
- 如果派生类重写了基类中某个虚函数,用派生类自己的虚函数覆盖表中基类的虚函数
- 派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后
7.3 多态的原理
class Person { public: virtual void BuyTicket() { cout << "买票-全价" << endl; } }; class Student : public Person { public: virtual void BuyTicket() { cout << "买票-半价" << endl; } }; void Func(Person& p) { p.BuyTicket(); } int main() { Person Mike; Func(Mike); Student Johnson; Func(Johnson); return 0; }
7.3.1 基类指针或引用进行调用虚函数理由
对于多态来说,Func函数传Person调用的Person::BuyTicket,传Student调用的是Student::BuyTicket。
具体说明:
- 构成多态的条件其中一条:通过基类的指针或引用进行调用虚函数
- 根据图中信息可得,基类对象被基类类型指针指向,通过基类中虚表指针找到虚表中的虚函数;派生类对象被基类类型指针指向,指向派生类中基类切片那部分。
- 指向派生类基类切片那部分,导致编译器无法区分基类是指向基类本身,还是指向派生类中切片中包含基类部分。但是编译器不会主动去区分它所指向的是一个实际的基类对象还是派生类对象中的基类部分。
- 编译器通过基类类型来限制访问的范围,而虚函数的动态绑定通过虚表指针和虚表来确保正确的函数调用。
- 体现了切片的作用及其为什么需要通过基类的指针或者引用调用
- 相同类型的类,共享同一块虚表。运行时去指向对象虚函数表中找BuyTicket的地址。
7.3.2 不满足多态情况
如果出现不满足多态的情况,编译链接根据调用对象类型,确定调用函数及其函数地址
小结:
- 多态调用:运行时,到指向对象的虚表中找虚函数调用,做到指向父类调用父类的虚函数,指向子类调用子类的虚函数
- 普通调用:编译时,调用对象是哪个类型,就调用他的函数
- 虚表:虚函数表,存的虚函数,目标实现多态
- 虚基表:存的当前位置记录虚基类部分的偏移量,解决菱形继承导致的数据冗余和二义性
7.3.3 反汇编中情况
void Func(Person* p) { ... p->BuyTicket(); // p中存的是mike对象的指针,将p移动到eax中 001940DE mov eax,dword ptr [p] // [eax]就是取eax值指向的内容,这里相当于把mike对象头4个字节(虚表指针)移动到了edx 001940E1 mov edx,dword ptr [eax] // [edx]就是取edx值指向的内容,这里相当于把虚表中的头4字节存的虚函数指针移动到了eax 00B823EE mov eax,dword ptr [edx] // call eax中存虚函数的指针。这里可以看出满足多态的调用,不是在编译时确定的,是运行起来以后到对象的中取找的。 001940EA call eax 00头1940EC cmp esi,esp } int main() { ... // 首先BuyTicket虽然是虚函数,但是mike是对象,不满足多态的条件,所以这里是普通函数的调用转换成地址时,是在编译时已经从符号表确认了函数的地址,直接call 地址 mike.BuyTicket(); 00195182 lea ecx,[mike] 00195185 call Person::BuyTicket (01914F6h) ... }
八、虚函数与虚表存储内存区域
问题:
- 虚函数存储在哪的?
- 虚表存储在哪的?
错误答案:虚函数存在虚表,虚表存在对象中,这里答案是错误的。
接下来我们可以通过打印地址来观察,这样是一种小技巧
int main() { 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); Person p; Student s; Person* p3 = &p; Student* p4 = &s; printf("Person虚表地址:%p\n", *(int*)p3); printf("Student虚表地址:%p\n", *(int*)p4); return 0; }
从打印结构来看,关于上面两个问题,我们可以得到答案
答案:
- 虚函数存储在代码段,同普通函数一样。
- 虚表存储在常量区,虚表存储是虚函数指针,而不是虚函数
- 虚表属于类,不归属函数局部中,因此不应该存储在栈上
【C++】面向对象编程的三大特性:深入解析多态机制(三)https://developer.aliyun.com/article/1617399