多态:虚表才活到运行期,符号表早已消失
我的github:(https://github.com/xcx55/ubuntu-linux-project)
感谢各位大佬参观我的github!!
一个东西有多种形态,就是多态。同一个函数名,表现出不同的形态------这是最短的定义,但这篇要回答的是更难的两个问题:多态为什么必须在运行期 ,以及它到底查的是哪张表。
一、先分清两种"多态"
静态的多态 :编译期就确定形态。函数重载就是它------为什么重载能同名?因为编译器会用参数修饰函数名,以参数的首字母来修饰函数的名字,相当于参数也是函数名字之一 。汇编窗口里看得一清二楚:func(double) 和 func(double*, int) 编译后是 _Z4funcd 和 _Z4funcPdi 两个完全不同的符号。各个编译器修饰规则不一样,但思路一致:把参数编进名字里。
动态的多态 :运行期才确定形态。这才是本篇的主角------它不靠改名字,靠虚函数表。
二、多态诞生的原因:编译期不跑代码
笔记里这段推理是全篇的地基,原样拆开:
- 我们对一个指针赋值之后,往往是想用它指向的东西的方法;
- 但编译期我们不知道它指向的是什么------
new、cin这些都要到运行期才有结果; - 编译期不会跑代码,它只知道类型和直接赋值的量;
- 可静态绑定又在编译期就完成了------我们调用函数的时刻,远在运行期以前,却想调用运行期才能确定的东西的方法。
矛盾就在这里。多态就是给这个矛盾开的口子:通过指针/引用调用 virtual 函数,可以强制跳过静态识别,直接去这个指针最终指向的对象里查虚表、调方法。
三、虚表:一个函数指针数组
机制并不神秘:
- 类里有
virtual修饰的函数,这个类就会生成一张虚函数表,存虚函数的地址; - 虚表的本质就是一个函数指针数组 ,对象里多出来的那个指针
_vfptr指向它; - 每一个类域都有其独属的虚函数表 ;子类的虚表是从父类拷贝 一份来的,然后检查子类的函数实现,发现重写(覆盖)就用子类的地址覆盖拷贝来的那一份------"覆盖"这个词,就是基于虚表说的;
- 子类新写的 virtual 函数,也会直接进自己的虚表(父类拷来的部分仍在)。
调试器里看得见:Base 对象的第一个成员就是 __vfptr,指向 {Base::vftable[2]}。
四、两条触发条件,缺一不可
实现多态必须同时满足:
- 被调用的函数必须是虚函数,并且完成了虚函数重写/覆盖(重写条件很严:名字、参数必须一致,返回类型协调------协变除外;派生类重写时不加 virtual 也自动构成重写);
- 必须是基类的指针或者引用去调用------对象调用永远不走多态。
为什么指针/引用是硬门槛?因为指针和引用的类型与其字节数划定有关系 :子类对象赋给父类对象是直接切片,父类就是个纯父类;而指针/引用只是"指向父类的那一部分",对象里那块还留着 _vfptr,虚表才找得到。
还有一个反直觉的点:多态不受访问权限控制 ------private 的虚函数照样能构成多态调用。原因很简单:多态发生在运行期,那时链接期早已结束,权限是编译期的规则,管不到运行期的查表。
五、重载、重写、隐藏:一张对比表
这三个概念是最常考的,必须理解着记:
| 概念 | 作用域 | 条件 |
|---|---|---|
| 重载 | 两个函数在同一作用域 | 函数名相同,参数不同(类型或个数),返回值可同可不同 |
| 重写/覆盖 | 分别在继承体系的父类和子类不同作用域 | 函数名、参数、返回值都必须相同(协变除外),且都必须是虚函数 |
| 隐藏 | 分别在父类和子类不同作用域 | 函数名相同,只要不构成重写就是隐藏;父子成员变量相同也叫隐藏 |
重写是"比隐藏严格得多"的关系:参数、名字一样,返回类型只是"稍微开放"(协变)------它就是专门为多态设计的。
六、默认参数:一个精妙的例外
函数体遵循动态多态------运行时根据对象实际类型决定调用哪个版本;但默认参数值遵循静态绑定 ------编译时根据指针/引用的静态类型决定用哪个默认值。
道理和第五节一脉相承:默认参数是语法糖,是编译期填进去的;而"判断是否多态"虽然也在静态条件下做,但填的是虚表地址。同一个调用点,一半在编译期定死,一半在运行期揭晓。
七、汇编现场:call eax
动态绑定和静态绑定在汇编上的区别一目了然:
asm
; 多态调用(虚函数)
mov eax, dword ptr [ptr] ; 拿到对象
mov edx, dword ptr [eax] ; 拿到虚表指针 → 虚表
mov ecx, dword ptr [ptr]
mov eax, dword ptr [edx] ; 从虚表里取出函数地址
call eax ; eax 里是什么,运行期才知道
; 普通调用(非虚函数)
mov ecx, dword ptr [ptr]
call Person::BuyTicket (07015DCh) ; 地址写死在指令里
动态绑定,就是在汇编层面 eax 是不确定的值------要运行期才把地址传入;静态绑定的 call 后面直接就是写死的地址。
八、为什么查虚表,不查符号表
那句结论值得单独一节:运行期,符号表早已消失,只有虚表存活。
编译期的一切查找(域、声明、权限)都围绕符号表;链接完成后大表替换完地址,符号表的使命就结束了。而虚表跟着对象走------对象在哪,_vfptr 就在哪。所以多态的查找路径是:
- 一般调用:以调用量的类型域去查;
- 继承:子类查不到就去父类查;
- 多态 :以指针/引用最终指向的东西的类型域去查(不看指针自己的类型),同样遵循继承的查找原则。
归根到底,都是前往域去查找------只是"以谁的类型为准"和"在哪个时期查"不同。虚函数和实函数对比,只是刚开始的查找规则不一样,后面的规则完全一样。
九、析构函数为什么最好是虚函数
这是多态最实用的落点。delete 的工作原理:先调用析构函数,再调 operator delete 释放空间。
cpp
Fu* pa = new fu;
pa = new zi;
delete pa; // 我们不想按 pa 的类型去找 ~Fu
我们希望析构是多态调用 ------指向谁,调用谁的析构函数。如果析构不构成多态,delete pa 就按指针类型调 ~Fu,子类那部分资源没人清理。而 operator delete 内部的调用序列是 ~B(); ~A();------派生类先析构,再析构父类,这套顺序靠 this 指针串起来,但 this 只有一种类型,所以必须让析构函数构成多态,各自才能被正确调用。
另外注意缺省值的坑:重写要求**参数(连缺省值也一样)**一致------void func(int val = 1) 的默认值如果是静态绑定的,父类子类缺省值不同时,会出现"函数体是子类的、默认参数是父类的"这种缝合怪。
十、收尾:多态的前提是继承
回头看那句开篇的疑问------"这么说,多态需要继承??!!!"
需要。继承不是拷贝,是派生类和基类都会扫一遍:编译期优先去派生类找函数,然后是基类,因为编译期知道这个类是派生类;而虚表这条线,父类建表、子类拷贝并覆盖、指针触发查表------三层环环相扣。没有继承,就没有"拷贝下来的虚表",多态无从谈起。
一句话总结:静态多态把参数编进名字,动态多态把地址存进虚表;前者在编译期定终身,后者把决定权留到运行期。