三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

【C++ 面试真题】聊聊 C++ 的虚函数和多态

【C++ 面试真题】聊聊 C++ 的虚函数和多态

【C++ 面试真题】聊聊 C++ 的虚函数和多态

虚函数是 C++ 面试的"分水岭"——背得出"virtual 实现多态"只是及格,真考你的是"虚函数表长什么样、对象内存里存了什么、构造析构里调虚函数会发生什么"。一道题能从语法一路问到内存布局。本文把虚函数表、动态多态、纯虚函数串成一条线,一次讲透。


一、先说结论:虚函数 = 运行期才决定的调用

❓ 什么是虚函数?它和多态什么关系?

✅ 一句话:虚函数是一种"运行期才决定调用哪个版本"的成员函数,它是 C++ 实现动态多态的机制。

普通成员函数的调用,在编译期就定死;虚函数的调用,要到运行期根据对象实际类型来决定——这就是多态。

structAnimal{virtualvoidspeak(){}virtual~Animal()=default;};structDog:Animal{voidspeak()override{std::cout<<"汪";}};
Animal*a=newDog;// 运行期才知道 a 指向 Doga->speak();// 打印"汪"// 这就是多态

a的静态类型是Animal*,但实际指向Dog对象。没有 virtual,a->speak()会调Animal::speak;有了 virtual,它会调Dog::speak这就是动态多态


二、虚函数表:多态是怎么实现的?

❓ 虚函数到底是怎么实现"运行期决定调用"的?

✅ 靠虚函数表(vtable)虚表指针(vptr)。这是这题的硬核,必须讲清。

机制分三层:

  1. 每个有虚函数的类,编译器给它生成一张虚函数表——一个存放虚函数地址的数组;
  2. 每个该类的对象,内存里多一个虚表指针 vptr,指向自己所属类的虚表;
  3. 虚函数调用时,通过对象的 vptr 找到虚表,再查表调到正确版本。
Dog 对象的内存布局: +----------+ | vptr ----+---> Dog 的虚函数表 +----------+ +------------+ | 成员... | | &Dog::speak| +----------+ +------------+
Animal*a=newDog;// a->speak() 实际做的事:// 1. 通过 a 找到对象里的 vptr// 2. vptr 指向 Dog 的虚表// 3. 查表得到 Dog::speak 的地址// 4. 调用它

💡加分点(谁建表、谁设 vptr):虚表是编译期就生成好的(每个类一张,静态数据);vptr 是构造函数里设置/更新的——对象构造时,vptr 指向自己正在构造的那个类。这就引出后面"构造函数里调虚函数"的坑。


三、静态多态 vs 动态多态

❓ 多态只有虚函数这一种吗?

✅ 不是。C++ 有两种多态

类型时机机制例子
静态多态编译期函数重载、模板max(1,2)vsmax(1.0,2.0)
动态多态运行期虚函数a->speak()
// 静态多态:编译期定死template<typenameT>Tadd(T a,T b){returna+b;}add(1,2);// 调 int 版本add(1.0,2.0);// 调 double 版本// 动态多态:运行期才定Animal*a=newDog;a->speak();// 运行期查虚表

🎯区别:静态多态零开销(编译期就定,没查表),但灵活性低;动态多态有开销(要查虚表、间接调用),但能在运行期根据实际类型决定行为。面试常问"用模板还是虚函数",本质就是这两者的权衡。


四、构造/析构里调虚函数:经典大坑

❓ 在构造或析构函数里调用虚函数,会怎样?

不会发生多态——调到的是当前类的版本,不是子类的。这是 C++ 最经典的坑之一。

原因:对象的 vptr 在构造过程中逐层设置。构造基类时,vptr 指向基类;进入子类构造后,vptr 才更新指向子类。析构则反过来。

structBase{Base(){// 此时 vptr 指向 Baseinit();// 调 Base::init// 不是 Der::init!}virtualvoidinit(){}virtual~Base()=default;};structDer:Base{voidinit()override{}};
Der d;// 构造时 Base() 里调的是 Base::init

⚠️为什么这么设计?构造基类时,子类部分还没构造,此时调子类的虚函数会用到未初始化的成员——很危险。所以语言规定:构造/析构期间的虚函数调用,退化为当前类的普通调用。理解了这层"保护",就不会再踩这个坑。


五、纯虚函数与抽象类

❓ 什么是纯虚函数?抽象类有什么用?

纯虚函数是只有声明、没有定义的虚函数(= 0)。含有纯虚函数的类叫抽象类,不能实例化,只能作为接口被继承。

// 抽象类:接口structShape{// 纯虚函数:必须由子类实现virtualdoublearea()const=0;virtual~Shape()=default;};
// 子类必须实现 area,否则仍是抽象类structCircle:Shape{doubler;doublearea()constoverride{return3.14*r*r;}};// Shape s; // ❌ 抽象类不能实例化Circle c{2.0};Shape*p=&c;std::cout<<p->area();// 12.56

💡加分点

  • 纯虚函数也可以有定义(在类外写Shape::area() {...}),子类可显式调用——用于"提供默认实现但仍强制子类 override";
  • 抽象类是 C++ 实现接口编程的标准手段:基类只定契约,子类提供实现。

六、虚函数的代价

❓ 用虚函数有什么开销?

✅ 三方面代价:

  1. 运行期开销:每次虚函数调用要查虚表 + 间接跳转,比普通调用慢(且阻碍内联);
  2. 空间开销:每个对象多一个vptr(通常 8 字节),每个类多一张虚表
  3. 缓存影响:间接跳转破坏指令缓存局部性。

🎯实践建议:虚函数的开销在绝大多数业务代码里可以忽略——一次查表就几纳秒。只有性能敏感的热路径(如每帧百万次调用)才需要计较,那时考虑用模板/CRTP 做静态多态替代。别为了"怕慢"就不用虚函数,那是过早优化。


七、核心规则速查表

维度规则
多态实现虚函数 + 虚表 + vptr
虚表生成编译期,每个类一张
vptr 设置构造函数里,逐层更新
构造/析构里调虚函数退化为当前类版本,不多态
纯虚函数= 0,使类变抽象,不可实例化
开销查表+间接调用,每对象多一 vptr

八、面试高频追问

❓ Q1:基类析构函数为什么要是虚函数?

✅ 当你用基类指针 delete 一个子类对象时,若析构不是虚函数,只会调基类析构,子类析构不执行——子类资源泄漏。这是未定义行为的高发区。所以"有虚函数的类,析构几乎一定要虚"。

❓ Q2:虚函数能内联吗?

✅ 语法上能加 inline,但通常不会被内联——虚函数要查虚表,编译期定不下来。例外:编译期能确定实际类型时(直接通过对象而非指针/引用调用),编译器可能内联掉。

❓ Q3:虚函数表存在哪?

✅ 每个类一张,是编译期生成的静态只读数据(通常在 .rodata 段)。所有该类对象共享这一张虚表,只是各自的对象里有一个 vptr 指向它。

❓ Q4:构造函数能是虚函数吗?

不能。虚函数依赖 vptr,而 vptr 要在构造函数里设置——构造还没开始,vptr 还没就位,逻辑上矛盾。所以语言直接禁止虚构造函数。

❓ Q5:override 和 virtual 写哪个?

✅ 派生类重写虚函数,优先写 override(让编译器检查签名)。virtual 性质会沿继承链自动传递,派生类不写 virtual 也是虚函数;但 override 能挡住"签名写错导致没重写"的 bug(见关键字篇 final/override)。


九、总结速查表

概念一句话
虚函数运行期决定的成员函数
虚表 vtable每类一张,存虚函数地址
vptr每对象一个,指向所属类虚表
抽象类含纯虚函数,不可实例化
多态实现通过 vptr 查 vtable

一句话回顾

虚函数的本质是用虚表和 vptr 实现"运行期决定调用"。记住"每类一张虚表、每对象一个 vptr、构造析构里调虚函数会退化、基类析构要虚",这题就稳了。

如果您觉得本篇内容对你有帮助,欢迎点赞 👍、收藏 ⭐、转发 📢。下期我们聊聊运算符重载,敬请关注 👋

← 返回列表