不管是参加校招还是社招,做C++后端与客户端开发的程序员,基本都躲不开多态这个高频面试考点,虽说大多数人都能记下“多态就是一个接口对应多种实现”的定义,也清楚要借助virtual关键字实现,可一旦被追问底层具体的运行逻辑,大多都会思路卡顿、没法完整作答。
先分清:静态多态 vs 动态多态
不少人提起多态只会联想到虚函数,可C++里的多态其实分为两类,二者的函数绑定时机差异极大,这也是面试官常拿来提问的易混淆知识点。
1. 静态多态(编译期多态)
静态多态在程序编译阶段就敲定了函数调用关系,程序运行过程中不会再做更改,全程也不会产生多余的运行时性能消耗。
它的常见实现方式有函数重载、运算符重载以及模板,编译器会依据参数的类型和数量,直接匹配对应的函数内存地址,生成固定的调用指令,这种绑定方式也被叫做静态绑定。
这类多态运行速度快、效率高,可灵活性不足,没办法在程序运行时依照对象的实际类型切换执行逻辑,也是面试中面试官用来和动态多态做重点对比的内容。
2. 动态多态(运行期多态)
动态多态在编译阶段无法确定最终调用的函数,必须等到程序运行起来,根据对象的真实类型,才能找到并调用对应的函数。
想要实现动态多态,满足以下几个条件就可以,面试时直接记牢这几点就行:
- 不同类之间存在继承的层级关系
- 基类里把成员函数标注为虚函数
- 子类对这个虚函数进行重写也就是覆盖操作
- 通过基类指针或者基类引用来调用虚函数
而这种运行时动态绑定的功能,底层全靠两个核心结构支撑,分别是虚函数表(vtable)和虚表指针(vptr),这也是整篇文章要重点讲解的内容。
核心底层:虚函数表与虚表指针
首先给大家点明核心,编译器在编译阶段,会给带有虚函数的类生成专属的虚函数表,同时还会给每一个类对象悄悄添加一个虚表指针,这个指针会指向当前类所属的虚函数表。
一、虚函数表(vtable)
虚函数表本质就是一个静态的函数指针数组,它属于类本身而非单个对象,同一个类创建的所有对象都会共用这一张虚表,存储在程序只读数据段中,伴随整个程序运行始终存在。
虚函数表的生成规则十分简单,具体要求如下:
- 只要类内部定义了虚函数,编译器就会自动生成对应的虚表,按照虚函数声明的先后顺序,挨个存放虚函数的内存地址
- 子类要是重写了基类的虚函数,虚表中对应位置的函数地址,就会被替换成子类重写后的新函数地址
- 子类没有修改重写的虚函数,直接沿用基类虚函数原本的内存地址即可
- 如果是纯虚函数,表中对应地址位置会设为空,强制要求子类必须完成重写,不然子类没办法正常创建实例对象
二、虚表指针(vptr)
虚表指针是编译器为对象添加的隐藏成员,属于对象自身的一部分,每个带有虚函数的对象,都会多占用一个指针大小的内存空间,32位系统占4字节,64位系统则占8字节。
这个指针会在对象构造的过程中完成初始化,创建对象时,构造函数会自动将vptr指向当前类的虚函数表,这也是父类指针指向子类对象时,能够找到子类虚表的根本原因。
一步步拆解:动态多态调用流程
只记住理论知识点远远不够,面试时需要说清完整的调用流程,接下来就用一段简洁的代码给大家举例讲解:
class Base {
public:
virtual void func() {
cout << "Base::func()" << endl;
}
};
class Derive : public Base {
public:
void func() override {
cout << "Derive::func()" << endl;
}
};
int main() {
Base* ptr = new Derive();
ptr->func();
delete ptr;
return 0;
}
上述代码实现多态调用的过程,底层具体执行步骤如下:
- 编译环节:编译器会为Base类生成专属虚表并存放Base::func函数地址,同时为Derive类生成独立虚表,由于子类重写了func函数,表内对应地址位会替换成Derive::func的地址
- 对象实例化:执行new Derive()创建对象时,会先初始化基类成员部分,再初始化子类成员部分,同时把对象内部的vptr指向Derive类的虚表
- 指针赋值:基类指针ptr指向子类实例对象后,即便指针本身类型为Base,它指向对象内部的vptr依旧会指向Derive类的虚表
- 函数调用:通过ptr调用func函数时,编译器不会直接锁定函数地址,而是先取出ptr指向对象的虚表指针vptr,再通过vptr找到对应的虚函数表,最后依照函数偏移量找到表中对应的函数地址完成调用。
经过以上步骤,最终调用的就是Derive::func函数,也就能顺利实现运行时多态的效果。
面试高频追问:这些核心细节务必吃透
1. 为何必须用指针/引用,不能直接用对象值传递?
如果直接用基类对象接收子类对象,就会出现对象切片的问题,子类独有的成员会被截断,对象内部的虚表指针也会变成基类的vptr,最终只会调用基类函数,完全实现不了多态效果。
2. 构造函数可以定义为虚函数吗?
答案是绝对不可以,虚函数需要依靠虚表指针才能正常运行,可虚表指针又是在构造函数里完成初始化的,要是构造函数设为虚函数,就会陷入需要先有虚表指针才能调用构造函数的死循环,逻辑上完全说不通,编译器也会禁止这种写法。
3. 为什么推荐将析构函数设为虚函数?
当基类指针指向堆内存中的子类对象时,如果析构函数不是虚函数,执行delete释放指针时,只会调用基类的析构函数,子类的析构函数无法运行,进而造成内存泄漏的问题。
把析构函数设为虚函数之后,子类析构函数会替换虚表里对应的地址,执行delete释放时会先运行子类析构函数,再运行基类析构函数,从而把对象占用的资源全部释放干净。
4. 多态会带来哪些性能开销?
很多面试官还会顺带问到多态的性能问题,对应的答案也很好理解:
- 内存开销:每个对象会多占用一个虚表指针的空间,每个带有虚函数的类也会多生成一张虚函数表
- 时间开销:函数调用时多了一层查找寻址的步骤,需要查询虚函数表,运行速度比普通成员函数稍慢一点
总结
C++动态多态的底层原理,就是依靠虚函数表和虚表指针,在程序运行时找到对应函数,编译器会提前生成好虚函数地址表,对象通过指针定位这张表,运行过程中查表完成函数调用。
面试时不用死记硬背,按照多态分类、动态多态实现条件、虚表与虚指针、调用流程、易错点的顺序梳理回答,既能展现扎实的编程基础,又能体现对底层逻辑的理解,轻松拿下这个高频考点。
平时写代码也不要随意使用虚函数,只有需要实现运行时多态的时候再开启,毕竟这类细微的性能损耗,在高并发场景下也会被不断放大。