多态-虚表才活到运行期符号表早已消失

多态:虚表才活到运行期,符号表早已消失

我的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]}。

四、两条触发条件,缺一不可

实现多态必须同时满足:

  1. 被调用的函数必须是虚函数,并且完成了虚函数重写/覆盖(重写条件很严:名字、参数必须一致,返回类型协调------协变除外;派生类重写时不加 virtual 也自动构成重写);
  2. 必须是基类的指针或者引用去调用------对象调用永远不走多态。

为什么指针/引用是硬门槛?因为指针和引用的类型与其字节数划定有关系 :子类对象赋给父类对象是直接切片,父类就是个纯父类;而指针/引用只是"指向父类的那一部分",对象里那块还留着 _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) 的默认值如果是静态绑定的,父类子类缺省值不同时,会出现"函数体是子类的、默认参数是父类的"这种缝合怪。

十、收尾:多态的前提是继承

回头看那句开篇的疑问------"这么说,多态需要继承??!!!"

需要。继承不是拷贝,是派生类和基类都会扫一遍:编译期优先去派生类找函数,然后是基类,因为编译期知道这个类是派生类;而虚表这条线,父类建表、子类拷贝并覆盖、指针触发查表------三层环环相扣。没有继承,就没有"拷贝下来的虚表",多态无从谈起。

一句话总结:静态多态把参数编进名字,动态多态把地址存进虚表;前者在编译期定终身,后者把决定权留到运行期。

相关推荐
chenbingjie_c1 小时前
C++ 多态完全指南:从虚函数重写到虚函数表原理
开发语言·c++
是main不是漫1 小时前
【双指针——移动零】数组划分类题目的入门基石
c++·算法
·present·1 小时前
CSAPP(Bomb lab)
c语言·c++
王老师青少年编程2 小时前
2026年信奥赛c++普及组csp-j第二轮复赛真题
c++·真题·csp·信奥赛
时间的拾荒人3 小时前
CMake 常用命令详解:从版本声明到安装部署
c++·面试
D e re k ZH5 小时前
P5025 [SNOI2017] 炸弹
c++·算法
wuminyu6 小时前
Java字节码学习总结:字节码如何加载、解析和执行
java·linux·c语言·jvm·c++
qq_406981036 小时前
c++的decltype是什么
开发语言·c++
坤坤子吖6 小时前
算法学习——单调队列
开发语言·c++·笔记·学习·算法