【C++】多态 -- 详解(上)https://developer.aliyun.com/article/1514712?spm=a2c6h.13148508.setting.17.4b904f0ejdbHoA
2、多态的原理
多态的原理到底是什么?还记得这里 Func 函数传 Person 调用的 Person::BuyTicket,传 Student 调用的是 Student::BuyTicket。
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; }
- 观察下图的红色箭头我们看到,p 是指向 mike 对象时,p->BuyTicket 在 mike 的虚表中找到虚函数是 Person::BuyTicket。
- 观察下图的蓝色箭头我们看到,p 是指向 johnson 对象时,p->BuyTicket 在 johson 的虚表中找到虚函数是 Student::BuyTicket。
- 这样就实现出了不同对象去完成同一行为时,展现出不同的形态。
- 反过来思考我们要达到多态,有两个条件,一个是虚函数覆盖,一个是对象的指针或引用调用虚函数。反思一下为什么?
(1)基类对象的指针 / 引用调用虚函数的原理是什么?
不管基类指针 / 引用指向的是基类还是派生类,执行这段代码 p->BuyTicket() 的指令是一模一样的,都是先找到虚表指针(对象中的头 4 个字节),通过虚表指针找到虚表,取对应虚函数的地址并调用该虚函数。
- 再通过下面的汇编代码分析,可以看出满足多态以后的函数调用,不是在编译时确定的,而是运行起来以后到对象的中去找的。不满足多态的函数调用是编译时确认好的。
class Person { public: virtual void BuyTicket() { cout << "买票-全价" << endl; } }; 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 001940EC cmp esi,esp } int main() { Person Mike; Func(&Mike); return 0; }
(2)为什么多态必须要用基类的指针 / 引用来调用虚函数,而用基类对象调用却不行?
派生类对象赋值给基类对象,不会拷贝派生类的虚表指针,只会拷贝对象中的数据成员过去。
不妨这样来理解:一个类的所有对象共享同一张虚表,就像一个类的所有对象共享成员函数一样,只能供这个类自己的对象使用,所以派生类对象是不可能把虚表拷贝过去的,不然就违背同一个类共享的规则了。
那么既然不会把派生类的虚表指针拷贝过去,那基类对象自然就不能调用到派生类的虚函数了。
int main() { //... // 首先BuyTicket虽然是虚函数,但是Mike是对象,不满足多态的条件,所以这里是普通函数的调用转换成地址时,是在编译时已经从符号表确认了函数的地址,直接call地址 Person Mike; Mike.BuyTicket(); 00195182 lea ecx,[mike] 00195185 call Person::BuyTicket (01914F6h) //... }
下面是上面继承关系中的 Person 类对象 Mike 和 Student 类对象 Johnson 模型:
解释了用基类引用 / 指针引用不同对象去完成同一行为时,如何展现出不同的形态。
3、动态绑定与静态绑定
- 静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为编译时多态性和静态多态,比如:函数重载、内联函数、函数模板。
- 动态绑定又称后期绑定(晚绑定),是在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也称为运行时多态性和动态多态,比如:虚函数。
- 前面买票的汇编代码很好的解释了什么是静态(编译器)绑定和动态(运行时)绑定。
五、单继承和多继承关系的虚函数表
需要注意的是在单继承和多继承关系中,下面我们去关注的是派生类对象的虚表模型,因为基类的虚表模型前面我们已经看过了,没什么需要特别研究的。
1、 单继承中的虚函数表
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; } virtual void func3() { cout << "Derive::func3" << endl; } virtual void func4() { cout << "Derive::func4" << endl; } private : int b; };
观察下图中的监视窗口中我们发现看不见 func3 和 func4。
这里是编译器的监视窗口故意隐藏了这两个函数,也可以认为是他的一个小 bug。
那么我们如何查看 d 的虚表呢?下面我们使用代码打印出虚表中的函数。
// 函数指针VFPTR typedef void(*VFPTR) (); // 打印虚表,传入虚函数指针数组 void PrintVTable(VFPTR vTable[]) { // 依次取虚表中的虚函数指针打印并调用。调用就可以看出存的是哪个函数 cout << " 虚表地址>" << vTable << endl; for (int i = 0; vTable[i] != nullptr; ++i) { // 依次打印虚表各元素 printf(" 第%d个虚函数地址 :0X%x,->", i, vTable[i]); // 把虚表各元素由void*强转为函数指针类型后,赋值给函数指针f VFPTR f = vTable[i]; // 调用函数 f(); } cout << endl; } int main() { Base b; Derive d; // 思路:取出b、d对象的头4字节,就是虚表的指针,前面我们说到虚函数表本质是一个存虚函数指针的指针数组,这个数组最后面放了一个nullptr // 1、先取b的地址,强转成一个int*的指针 // 2、再解引用取值,就取到了b对象头4字节的值,这个值就是指向虚表的指针 // 3、再强转成VFPTR*,因为虚表就是一个存VFPTR类型(虚函数指针类型)的数组。 // 4、虚表指针传递给PrintVTable进行打印虚表 // 5、需要说明的是这个打印虚表的代码经常会崩溃,因为编译器有时对虚表的处理不干净,虚表最后面没有放nullptr,导致越界,这是编译器的问题。我们只需要点目录栏的-生成-清理解决方案,再编译就好了。 VFPTR* vTableb = (VFPTR*)(*(int*)&b); PrintVTable(vTableb); // 打印对象b的虚表 VFPTR* vTabled = (VFPTR*)(*(int*)&d); PrintVTable(vTabled); // 打印对象d的虚表 return 0; }
2、多继承中的虚函数表
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; } virtual void func3() { cout << "Derive::func3" << endl; } private: int d1; }; // 函数指针VFPTR typedef void(*VFPTR) (); // 打印虚表,传入虚函数指针数组的地址(即虚表指针) void PrintVTable(VFPTR vTable[]) { cout << " 虚表地址>" << vTable << endl; for (int i = 0; vTable[i] != nullptr; ++i) { // 依次打印虚表各元素 printf(" 第%d个虚函数地址 :0X%x,->", i, vTable[i]); // 把虚表各元素赋值给函数指针f VFPTR f = vTable[i]; // 调用函数 f(); } cout << endl; } int main() { Derive d; VFPTR* vTableb1 = (VFPTR*)(*(int*)&d); PrintVTable(vTableb1); // 打印第一张虚表 // 必须先强转成char*,然后加Base1大小个字节,再强转成int*,解引用,强转成VFPTR* VFPTR* vTableb2 = (VFPTR*)(*(int*)((char*)&d+sizeof(Base1))); PrintVTable(vTableb2); // 打印第二张虚表 return 0; }
(1)Base1 和 Base2 中都有虚函数 func1,那么 Derive 类中的 func1 到底是重写的哪一个基类的呢?
两个基类 Base1 和 Base2 中的虚函数 func1 都会被重写,因为要满足多态条件。
(2)多继承体系,Derive 继承了两个基类,那么 Derive 对象中有几张虚表呢?
Derive 对象中有两张虚表。
观察下图可以看出:多继承派生类的未重写的虚函数放在第一个继承基类部分的虚函数表中。
这里 Derive 对象的两张虚表中的重写的 Derive::func1 函数,虽然函数地址不一样,但是当 Base1 或 Base2 指针指向 Derive对象时,调的都是 Derive 中的 func1,是同一个函数。这其中的具体原因和编译器的设计有关。
3、菱形继承、菱形虚拟继承
在实际中我们不建议设计出菱形继承及菱形虚拟继承,一方面太复杂容易出问题,另一方面这样的模型,访问基类成员有一定得性能损耗。所以菱形继承、菱形虚拟继承我们的虚表就不深入探究了,一般我们也不需要研究清楚,因为实际中很少用。
如果对此感兴趣的可以查看下面两篇博客:
C++ 虚函数表解析 | 酷 壳 - CoolShell
C++ 对象的内存布局 | 酷 壳 - CoolShell