【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)。这是这题的硬核,必须讲清。
机制分三层:
- 每个有虚函数的类,编译器给它生成一张虚函数表——一个存放虚函数地址的数组;
- 每个该类的对象,内存里多一个虚表指针 vptr,指向自己所属类的虚表;
- 虚函数调用时,通过对象的 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++ 实现接口编程的标准手段:基类只定契约,子类提供实现。
六、虚函数的代价
❓ 用虚函数有什么开销?
✅ 三方面代价:
- 运行期开销:每次虚函数调用要查虚表 + 间接跳转,比普通调用慢(且阻碍内联);
- 空间开销:每个对象多一个vptr(通常 8 字节),每个类多一张虚表;
- 缓存影响:间接跳转破坏指令缓存局部性。
🎯实践建议:虚函数的开销在绝大多数业务代码里可以忽略——一次查表就几纳秒。只有性能敏感的热路径(如每帧百万次调用)才需要计较,那时考虑用模板/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、构造析构里调虚函数会退化、基类析构要虚",这题就稳了。
如果您觉得本篇内容对你有帮助,欢迎点赞 👍、收藏 ⭐、转发 📢。下期我们聊聊运算符重载,敬请关注 👋