面试官最爱问:C++ 多态底层到底是怎么实现的?

· C++编程教程

不管是参加校招还是社招,做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;
}

上述代码实现多态调用的过程,底层具体执行步骤如下:

  1. 编译环节:编译器会为Base类生成专属虚表并存放Base::func函数地址,同时为Derive类生成独立虚表,由于子类重写了func函数,表内对应地址位会替换成Derive::func的地址
  2. 对象实例化:执行new Derive()创建对象时,会先初始化基类成员部分,再初始化子类成员部分,同时把对象内部的vptr指向Derive类的虚表
  3. 指针赋值:基类指针ptr指向子类实例对象后,即便指针本身类型为Base,它指向对象内部的vptr依旧会指向Derive类的虚表
  4. 函数调用:通过ptr调用func函数时,编译器不会直接锁定函数地址,而是先取出ptr指向对象的虚表指针vptr,再通过vptr找到对应的虚函数表,最后依照函数偏移量找到表中对应的函数地址完成调用。

经过以上步骤,最终调用的就是Derive::func函数,也就能顺利实现运行时多态的效果。

面试高频追问:这些核心细节务必吃透

1. 为何必须用指针/引用,不能直接用对象值传递?

如果直接用基类对象接收子类对象,就会出现对象切片的问题,子类独有的成员会被截断,对象内部的虚表指针也会变成基类的vptr,最终只会调用基类函数,完全实现不了多态效果。

2. 构造函数可以定义为虚函数吗?

答案是绝对不可以,虚函数需要依靠虚表指针才能正常运行,可虚表指针又是在构造函数里完成初始化的,要是构造函数设为虚函数,就会陷入需要先有虚表指针才能调用构造函数的死循环,逻辑上完全说不通,编译器也会禁止这种写法。

3. 为什么推荐将析构函数设为虚函数?

当基类指针指向堆内存中的子类对象时,如果析构函数不是虚函数,执行delete释放指针时,只会调用基类的析构函数,子类的析构函数无法运行,进而造成内存泄漏的问题。

把析构函数设为虚函数之后,子类析构函数会替换虚表里对应的地址,执行delete释放时会先运行子类析构函数,再运行基类析构函数,从而把对象占用的资源全部释放干净。

4. 多态会带来哪些性能开销?

很多面试官还会顺带问到多态的性能问题,对应的答案也很好理解:

总结

C++动态多态的底层原理,就是依靠虚函数表和虚表指针,在程序运行时找到对应函数,编译器会提前生成好虚函数地址表,对象通过指针定位这张表,运行过程中查表完成函数调用。

面试时不用死记硬背,按照多态分类、动态多态实现条件、虚表与虚指针、调用流程、易错点的顺序梳理回答,既能展现扎实的编程基础,又能体现对底层逻辑的理解,轻松拿下这个高频考点。

平时写代码也不要随意使用虚函数,只有需要实现运行时多态的时候再开启,毕竟这类细微的性能损耗,在高并发场景下也会被不断放大。