拓冰建站拓冰建站
首页 / 资讯中心 / 正文

C++虚函数详解:构造函数不能虚,析构函数必须虚

1. 题目拆解这到底在考什么不管你是准备C面试还是写了好几年业务代码突然被同事问住这个问题出现的频率都相当高。表面上看它只是一个“是或否”的判断题但背后牵扯到虚函数机制、对象内存布局、构造和析构顺序、多态行为的边界可以说是一道典型的“八股文但又不完全是八股文”的题。先说结论省得你着急构造函数不能声明为虚函数。析构函数可以声明为虚函数而且当类有多态行为存在虚函数时强烈建议把析构函数声明为虚函数。但结论只是第一步。面试官或者你自己写代码时真正关心的是背后的为什么以及在什么场景下这个“为什么”会真的影响程序行为。这篇文章我把这条链路完整拆开讲从虚函数的工作原理一路讲到构造/析构函数里的虚函数调用陷阱最后附上几个我在实际项目中踩过的坑。先给一个最小可用的代码示例方便你直接跑起来验证#include iostream class Base { public: Base() { std::cout Base constructor std::endl; } virtual ~Base() { std::cout Base destructor std::endl; } virtual void func() { std::cout Base::func std::endl; } }; class Derived : public Base { public: Derived() { std::cout Derived constructor std::endl; } ~Derived() override { std::cout Derived destructor std::endl; } void func() override { std::cout Derived::func std::endl; } }; int main() { Base* ptr new Derived(); ptr-func(); delete ptr; return 0; }输出结果是Base constructor Derived constructor Derived::func Derived destructor Base destructor注意析构顺序先派生类后基类。这个顺序正是虚析构函数能正确工作的关键表现之一。你先把这个例子跑通再往下看原理就顺了。2. 构造函数为什么不能是虚函数2.1 虚函数机制的底层逻辑要解释这个问题得先弄清楚虚函数在C里到底是怎么实现的。绝大多数编译器几乎你能遇到的所有主流编译器采用的方法是每个包含虚函数的类在编译期会生成一张虚函数表vtable表中按声明顺序存放该类所有虚函数的地址。每个对象在构造时会有一个隐藏的指针成员——虚函数表指针vptr指向该类对应的虚函数表。当你调用一个虚函数时编译器生成的代码不是直接call某个固定地址而是从对象地址取出vptr。从vptr指向的vtable中取出对应槽位的函数指针。间接调用该函数指针。这个间接跳转就是“运行时多态”的地基。整个过程依赖一个前提对象的vptr已经被正确初始化并且指向了正确类型的vtable。2.2 鸡生蛋蛋生鸡的问题构造函数的作用是什么是初始化对象。vptr算不算对象的一部分算。那么问题来了如果构造函数本身是虚函数那么在构造函数被调用之前vptr根本还没有被初始化。既然vptr还没初始化编译器就无法通过vptr找到虚函数表也就无法定位到正确的构造函数。打个比方你想用一把钥匙打开保险柜但这把钥匙本身也存在保险柜里而保险柜在你出生之前就已经锁死了。你连钥匙都拿不到更别说打开柜子。这就是“先有鸡还是先有蛋”的死锁。构造函数作为对象创建的入口必须先存在、先被调用才能把vptr设置好。而虚函数机制又依赖vptr已经设置好。两者形成闭环所以构造函数无法被设计为虚函数。2.3 构造过程中对象的“真实类型”问题除了vptr尚未初始化这个硬性条件还有另一个层面的原因构造函数执行期间对象的动态类型是“当前正在构造的类”而不是最终派生类。C标准明确规定在基类构造函数执行期间虚函数调用不会分发到派生类的重写版本。也就是说即使你在基类构造函数里调用了一个虚函数实际运行的也是基类自己的版本而不是派生类的版本。这其实和vptr的赋值时机有关。编译器生成的构造函数代码会在构造函数体执行之前先设置vptr指向当前类的vtable。基类构造函数设置的vptr是基类的等到派生类构造函数开始执行时vptr才会被重新指向派生类的vtable。如果构造函数本身是虚函数那意味着对象在“完全构造好”之前就要参与多态分发这既不符合C的对象生命周期模型也会让编译器无法生成可靠的调用代码。所以从语言设计和实现两个层面构造函数都不能是虚函数。2.4 那“虚构造函数”的需求怎么办有人会问我想根据输入类型动态创建对象这不就是需要虚构造函数吗这个需求是真实存在的但C的解决方案不是虚构造函数而是虚构造函数的替代品工厂函数Factory在基类中定义一个static函数根据参数返回不同派生类对象的指针。虚克隆Virtual Clone利用虚函数实现对象的自我复制例如定义virtual Base* clone() const在派生类中返回new Derived(*this)。举个例子#include iostream #include memory class Base { public: virtual ~Base() default; virtual std::unique_ptrBase clone() const 0; virtual void identify() const 0; }; class DerivedA : public Base { public: std::unique_ptrBase clone() const override { return std::make_uniqueDerivedA(*this); } void identify() const override { std::cout DerivedA std::endl; } }; class DerivedB : public Base { public: std::unique_ptrBase clone() const override { return std::make_uniqueDerivedB(*this); } void identify() const override { std::cout DerivedB std::endl; } }; int main() { std::unique_ptrBase obj std::make_uniqueDerivedA(); auto copy obj-clone(); copy-identify(); // 输出 DerivedA return 0; }这段代码实现的“通过基类指针创建同类对象”的能力在Java、C#等语言里就是“虚构造函数”它们叫虚构造器或反射创建对象的效果。C虽然不支持虚构造函数但用克隆模式可以解决大部分需要动态创建对象的场景。3. 析构函数为什么可以且应该声明为虚函数3.1 析构函数和构造函数的不同处境析构函数与构造函数在虚函数问题上处境完全不同。析构函数被调用时对象已经存在于内存中vptr已经初始化类型信息完整完全满足虚函数分发的条件。更重要的是析构函数有强烈的多态需求当一个类被设计为基类时你几乎总是会通过基类指针来删除派生类对象。如果析构函数不是虚函数那么delete一个基类指针时编译器只会调用基类的析构函数派生类中可能申请的资源堆内存、文件句柄、网络连接、锁等永远不会被释放造成资源泄漏。3.2 内存泄漏的完整复现光说“会泄漏”不够直观我写一个可以真实观察到泄漏的代码#include iostream class Base { public: Base() : base_data(new int[100]) { std::cout Base allocate std::endl; } ~Base() { // 注意这里故意不加 virtual delete[] base_data; std::cout Base destructor std::endl; } private: int* base_data; }; class Derived : public Base { public: Derived() : derived_data(new int[1000]) { std::cout Derived allocate std::endl; } ~Derived() { delete[] derived_data; std::cout Derived destructor std::endl; } private: int* derived_data; }; int main() { Base* ptr new Derived(); delete ptr; // 危险行为 return 0; }运行输出Base allocate Derived allocate Base destructor看到了吗Derived destructor根本没有被调用derived_data指向的那块内存永远不会被释放。如果你在Visual Studio里跑配合内存诊断工具或者用Linux下的Valgrind都能明确看到内存泄漏报告。这就是经典的非虚析构函数导致的资源泄漏。如果把析构函数加上virtual输出就会变成Base allocate Derived allocate Derived destructor Base destructor资源释放干净顺序正确先释放派生类资源再释放基类资源。3.3 析构顺序为什么是先派生后基类可能有人会问即使析构函数是虚的为什么派生类析构函数执行完还要自动调用基类析构函数这是因为编译器在生成派生类析构函数时会隐式地在函数体末尾插入对基类析构函数的调用。这个机制保证了一个对象的完整析构链条每个类都只负责释放自己声明的那部分成员然后自动把“接力棒”交给基类。顺序上必须是先派生后基类因为派生类成员可能依赖基类成员仍然存活。如果先析构基类派生类成员再去访问基类数据成员或者调用基类虚函数时就会遇到未定义行为。3.4 纯虚析构函数的使用场景析构函数还有一个特殊用法可以声明为纯虚析构函数让类变成抽象类同时还能提供析构函数体。这个套路很多人第一次看到会疑惑纯虚函数不是不能有函数体吗其实纯虚析构函数是个特例它必须提供函数体。原因很实际如果析构函数没有函数体那么任何派生类对象在析构时编译器生成的“调用基类析构函数”这个动作将找不到可执行的代码链接时直接报错。典型的写法如下#include iostream class AbstractBase { public: virtual ~AbstractBase() 0; // 纯虚析构函数 }; AbstractBase::~AbstractBase() { std::cout AbstractBase destructor body std::endl; } class ConcreteDerived : public AbstractBase { public: ~ConcreteDerived() override { std::cout ConcreteDerived destructor std::endl; } }; int main() { AbstractBase* obj new ConcreteDerived(); delete obj; return 0; }输出ConcreteDerived destructor AbstractBase destructor body这个类不能用AbstractBase obj;实例化因为它是抽象类但作为基类它的析构函数体依然会在派生类析构时被调用。这种设计常用于你希望定义一个接口类强制所有派生类必须可析构同时基类自己也有一些公共资源需要清理。3.5 什么时候析构函数不需要虚业界对“基类析构函数必须为虚”有一个补充说明如果基类不是用来做多态删除的可以不声明为虚析构函数。典型例子是std::string、std::vectorint等类它们没有虚析构函数也不建议继承它们。如果你继承了std::vectorint然后通过std::vectorint*删除派生类对象一样会陷入析构不完整的陷阱。正确的做法是组合优于继承或者把这类值语义类当成“非多态类”使用不通过基类指针删除。这其实引出一个通用判断标准类中有任何虚函数吗如果是请加上虚析构函数。类会被其他类继承并且可能通过基类指针/引用删除对象吗如果是请加上虚析构函数。类只是值语义的工具类从设计上就不打算被多态继承可以不加虚析构函数但最好显式阻止继承C11起可以用final。从C11开始还有一个必须养成的习惯析构函数默认加上override关键字这样编译器能帮你检查是否真的覆盖了基类的虚析构函数拼写错误或者签名不匹配会直接报错而不是静默失效。4. 构造和析构函数内部调用虚函数的坑4.1 构造函数里调用虚函数结果不是你想的那样你可能在代码里写过类似这样的逻辑基类构造函数想调用一个虚函数让派生类提前做一些初始化工作。看起来很合理实际运行结果却让你怀疑人生。#include iostream class Base { public: Base() { setup(); } virtual void setup() { std::cout Base::setup std::endl; } }; class Derived : public Base { public: Derived() : Base() {} void setup() override { std::cout Derived::setup std::endl; } }; int main() { Derived d; return 0; }输出Base::setup你没有看错。虽然对象最终是Derived类型但在Base构造函数执行期间vptr 还指向Base的虚函数表所以setup()动态绑定到Base::setup而不是Derived::setup。C标准里的术语叫“构造期间的动态类型是当前正在构造的类”。也就是说构造过程中对象的多态行为是逐层退化的基类构造阶段它表现得像基类对象派生类构造阶段才开始表现得像派生类对象。这个坑在很多设计模式中都会遇到。如果你真想构造期间完成某些派生类特有的初始化推荐两种替代方案方案一使用工厂函数先构造完整对象再调用初始化函数class Base { public: virtual void init() 0; }; class Derived : public Base { public: void init() override { std::cout real init std::endl; } }; templatetypename T, typename... Args std::unique_ptrT createAndInit(Args... args) { auto obj std::make_uniqueT(std::forwardArgs(args)...); obj-init(); return obj; }方案二使用模板方法模式把“派生类行为”放在派生类构造函数的初始化列表中。4.2 析构函数里调用虚函数结果同样反直觉析构函数的情况和构造函数对称但方向相反。在析构期间对象的动态类型会先变成当前析构的类然后再逐层向上退化。换句话说析构函数的执行顺序是派生类析构函数体先执行然后是基类析构函数体。在派生类析构函数执行时vptr还指向派生类的虚函数表但一旦进入基类析构函数体vptr已经被修改为指向基类的虚函数表。来看一个具体的坑#include iostream class Base { public: virtual ~Base() { log(); } virtual void log() { std::cout Base::log std::endl; } }; class Derived : public Base { public: ~Derived() override { std::cout Derived destructor body std::endl; } void log() override { std::cout Derived::log std::endl; } }; int main() { Base* ptr new Derived(); delete ptr; return 0; }输出Derived destructor body Base::log注意最后一行是Base::log不是Derived::log。因为 Derived 的析构函数体执行完成后vptr 被调整回指向 Base 的 vtable然后才执行 Base 的析构函数体。如果你在基类析构函数里调用了虚函数绝对不会进入到派生类的重写版本。这对实际编码的启示是不要在基类析构函数里依赖任何派生类的运行时行为派生类相关的资源已经先一步释放完了此时把虚调用分发到派生类版本反而是不安全的。4.3 构造函数里为什么不能调用纯虚函数在构造函数里调用纯虚函数后果比调用普通虚函数更严重。普通虚函数至少还会回退到“当前构造类”的实现纯虚函数在基类中没有实现编译器一旦检测到构造函数调用了纯虚函数会直接拒绝编译或者链接时无法解析。这其实是对上述“动态类型退化”行为的一种强制保护。在实际项目中如果非要模拟“构造完成后执行某个派生类专属逻辑”可以这样设计#include iostream class Base { public: Base() {} // 什么也别做 void start() { init(); run(); } protected: virtual void init() 0; virtual void run() 0; }; class Derived : public Base { protected: void init() override { std::cout Derived init std::endl; } void run() override { std::cout Derived run std::endl; } }; int main() { Derived d; d.start(); // 对象已经完全构造虚函数正常分发 return 0; }这类“延迟初始化”模式在某些框架代码里很常见核心思想就一句话虚函数要发挥作用必须等对象构造函数完全执行完之后。5. 高频面试问题速查与避坑清单5.1 面试官最常追问的几个变体这道题作为经典C面试题面试官一般不会只停留在“能不能”这个层面常见的追问包括为什么构造函数不能是虚函数请从对象生命周期和vptr初始化角度解释。析构函数必须是虚函数吗如果基类没有虚函数但准备被继承析构函数要不要加virtual构造函数和析构函数里调用虚函数会发生什么会调用到派生类版本吗纯虚析构函数有什么用为什么需要函数体如果析构函数不是虚函数通过基类指针删除派生类对象会导致什么后果如何检测这些问题背后的考察点其实是对“对象的动态类型在构造/析构期间的变化”的理解深度。你可以先背结论但建议把前面第2章和第4章的代码都自己敲一遍观察输出结果才能真正答出彩。5.2 一份可直接收藏的开发检查清单结合多年的实际编码经验我总结了一份关于虚函数和析构函数的自查清单每次写类前过一遍能避开大部分常见的坑类中有虚函数吗有则加虚析构函数。类会被其他类继承且会通过基类指针删除吗会则加虚析构函数。析构函数加上override关键字了吗没有编译器无法帮你检查覆盖关系。构造函数里调用虚函数了吗如果是大概率设计不合理改工厂函数或模板方法。析构函数里调用虚函数了吗如果是基类析构阶段会绕过派生类版本检查依赖关系。基类有纯虚析构函数吗有务必在类外提供函数体。需要抽象基类但没有任何纯虚函数考虑用纯虚析构函数把类变成抽象类。5.3 我在真实项目中踩过的三个坑第一件事是很多年前在一个图形渲染引擎的插件系统里主程序定义了一个接口类没写虚析构函数。插件DLL里导出一个派生类对象主程序通过接口指针释放它。结果就是每次关闭应用都泄漏大量显存资源又因为DLL边界导致堆损坏崩溃现象偶发且极难定位。最后用内存检测工具查出是析构函数非虚导致的。那次之后我给自己定下规矩凡是有虚函数的类一律加虚析构函数这已经成了肌肉记忆。第二件事是某次代码审查发现一个同事在构造函数里调用了一个虚函数来做配置加载。表面上单测不报错因为测试对象本身就是这个类没有派生类覆盖。但后来有人继承了这个类并重写了那个虚函数结果配置逻辑完全不生效排查了两个小时才发现是“构造期间虚函数不外派”的机制在作怪。从那以后我对“构造函数做业务逻辑”这件事格外敏感宁可写一个start()显式方法。第三件事是关于std::unique_ptr和虚析构函数的配合。一个类有虚析构函数但没写virtual关键字之外的任何特殊成员函数编译器会隐式生成拷贝操作。如果类里持有原始所有权指针默认拷贝会导致双重释放。这虽然和虚析构函数本身关系不大但“用户自定义了析构函数”会影响移动操作的生成规则好多初学者在这里被绊倒。5.4 编译器和工具的辅助手段现代工具链提供了一些手段帮你提前发现这类问题。GCC和Clang都支持-Wsuggest-final-types和-Wdelete-non-virtual-dtor这类警告。Visual Studio的C代码分析/analyze也能检测到“通过基类指针删除非虚析构函数对象”的隐患。在实际工作中我会在CMake里统一开启这些编译选项if(MSVC) add_compile_options(/analyze) else() add_compile_options(-Wdelete-non-virtual-dtor -Wsuggest-final-types) endif()配合-Werror使用时这些警告直接升级为编译错误强迫你在代码合并前解决问题。6. 最后的经验总结这道题让我想起一个经验C里很多面试题表面上问语法实际上问的是对象模型的理解。构造函数和析构函数与虚函数的关系恰好把对象的创建、多态分发、资源释放、动态类型变化这几个核心概念串成了一条线。我个人在实际编码中最深的体会是虚函数不是万能的它有严格的生效时间窗口。对象在构造完成之前和析构开始之后虚函数机制都不能按直觉工作。理解这一点很多诡异的bug都能迎刃而解。如果你正在准备面试建议把本文的所有代码都跑一遍重点看第2章的“vptr初始化顺序”和第4章的“构造/析构期间调用虚函数”这两个输出结果。这两个问题答好了基本可以覆盖面试官对这道题的所有追问。如果你是在写长期维护的底层模块记住一条最朴素的规则任何设计成基类的类要么显式拒绝继承用final要么把析构函数声明为虚函数。中间状态最容易出事。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门