Effective C++条款九:构造函数与析构函数为何不能调用虚函数

发布时间:2026/10/11 23:21:05
Effective C++条款九:构造函数与析构函数为何不能调用虚函数 最近在代码评审里又见到一个老梗基类构造函数里调了一个 virtual 函数函数还特地标了override结果运行日志完全对不上。这类问题我在线上排查过不止一次现象都出奇一致——该多态的时候它死活不多态派生类的实现压根没被调起来。折腾到最后十有八九就是撞上了 Effective C 条款九那条铁律绝不在构造和析构过程中调用 virtual 函数。这条规则几乎所有 C 开发者都听过但真正理解“为什么不”的人不多实际踩坑时能快速反应过来的人更少。今天我就结合生产环境里真实遇到的场景把条款九从原理到替代方案彻底捋一遍。无论你是刚读完 Effective C 的新手还是在老项目里被诡异日志折磨的维护者这篇文章都能帮你省下半天排查时间。1. 规则本体条款九到底在说什么1.1 一句话版本的铁律条款九的原文表达可以浓缩成一句在构造函数和析构函数内部包括它们调用的任何函数直接或间接调用 virtual 函数调用结果都不会呈现多态性而是被静态解析为当前正在构造/析构的那个类的版本。举个例子基类Base的构造函数里有一句virtualize()派生类Derived重写了它。当你创建Derived对象时基类Base的构造函数先执行此时virtualize()调用的不是Derived::virtualize()而是Base::virtualize()。同理析构时先执行Derived的析构再执行Base的析构在Base析构里调用同一个函数还是会落到Base的实现上。这里有个常见理解误区很多人以为“只要函数是 virtual任何时候调用都会动态绑定”。实际上 C 的动态绑定有一个前提——当前对象的动态类型必须已经是最终类型。而构造和析构期间对象的动态类型是“正在构造/析构的那一层类”不是最终类。1.2 构造函数里调用 virtual 究竟会发生什么要看清机制得先明白 C 对象的构建顺序。创建派生类对象时编译器会先完成基类子对象的构造再构造派生类自己的成员变量最后执行派生类构造函数体。基类构造期间派生类部分连影子都没有成员变量尚未初始化虚表指针也还指着基类的虚表。在这种状态下如果基类构造函数调用了一个 virtual 函数对象能用的只有基类子对象的信息。C 标准规定构造或析构函数中包括由它们直接或间接调用的函数对虚函数的调用使用当前构造/析构类的函数版本。这是语言层面强制规定不是编译器的优化行为。1.3 析构函数里调用 virtual 会怎样析构顺序和构造完全相反先执行派生类析构函数体再销毁派生类成员最后执行基类析构函数。当执行到基类析构时派生类成员已经被销毁虚表指针也切回了基类。此时再调 virtual 函数会执行基类版本。如果你的派生类析构逻辑依赖某个资源或状态而基类析构里突然调用了 virtual 函数访问了这些已经被注销的东西轻则拿到错误数据重则直接崩溃。多态在析构期间同样“失灵”。2. 经典代码陷阱一个交易日志系统是怎么翻车的2.1 场景代码看起来天衣无缝的设计假设我们有一个交易系统每种交易类型需要记录不同的日志信息。于是写了这样一个基类class Transaction { public: Transaction() { logTransaction(); // 危险的写法 } virtual ~Transaction() default; virtual void logTransaction() const { std::cout Transaction: unknown type\n; } }; class BuyTransaction : public Transaction { public: BuyTransaction() default; void logTransaction() const override { std::cout Transaction: buy order\n; } }; class SellTransaction : public Transaction { public: SellTransaction() default; void logTransaction() const override { std::cout Transaction: sell order\n; } };乍看起来逻辑完美每种交易在构造时自动记录自己的类型。创建BuyTransaction对象时基类构造调logTransaction()虚函数应该定位到BuyTransaction的版本。2.2 运行结果多态去哪了实际跑一下输出是Transaction: unknown type而不是我们期望的Transaction: buy order。原因就是条款九的内容基类构造时对象的动态类型是Transaction虚表指针指向Transaction的虚表。于是logTransaction()解析成Transaction::logTransaction()。BuyTransaction连构造都还没开始谈何调用它的重写版本。我当年在项目里遇到这问题时第一反应是“编译器是不是坏了”第二反应是“是不是虚函数表指针在基类构造时没有初始化”。这两种猜测都错了C 语言规则从一开始就不允许这种多态调用有效。2.3 更隐蔽的间接调用比显式调用更坑的是间接调用。许多开发者知道不能在构造函数体里直接调 virtual但会在构造函数里调用另一个成员函数而那个成员函数内部调用了 virtualclass Transaction { public: Transaction() { init(); // 间接调用 } virtual ~Transaction() default; void init() { logTransaction(); // 看起来没直接在构造函数里调 virtual } virtual void logTransaction() const { std::cout Transaction: unknown type\n; } };这样做照样翻车。C 标准明确说即使是从构造/析构函数调用的普通成员函数里发起虚调用同样使用当前类的版本。条款九的灵魂不在于“你写了 virtual 的那一行代码在哪里”而在于“整个构造/析构调用链上都不应该出现多态依赖”。3. 我后来才想明白的底层机制3.1 虚表指针的切换时机C 每个含虚函数的对象都有一个虚表指针在构造过程中这个指针会逐层更新。构造Derived对象时大致流程是构造基类子对象虚表指针指向Base的虚表。基类构造完成虚表指针更新为指向中间层类的虚表。逐层向上直到指向Derived的虚表。构造Derived自己的成员变量和构造函数体。析构则是完全相反的序列执行Derived析构体时虚表指针还指向Derived等进入Base析构时指针已经回到Base的虚表。这就是为什么构造函数和析构函数里的虚调用“看起来静态化”了——因为此刻虚表指针的状态确实只能支持当前层的多态。语言设计者把这个行为固化为规则而不仅仅是依靠虚表指针的实现巧合。3.2 成员初始化顺序决定了不可能安全多态假设在基类构造期间真的允许调用派生类的重写函数那这个函数内部访问派生类成员变量怎么办那些成员变量还没构造访问就是未定义行为。即使不访问成员派生类重写函数可能依赖某些派生类构造后的不变量而这些不变量此刻根本不存在。C 的设计哲学是“不给未定义行为留机会”。如果在构造期间允许动态绑定任何一个粗心的重写函数都可能访问未初始化的成员。干脆规定死构造和析构期间虚函数的调用不进行动态绑定直接静态解析。这也解释了为什么基类构造里调用纯虚函数会导致未定义行为——纯虚函数在基类里根本没有有效实现可以兜底而派生类实现又无法被调用。3.3 析构时期的秩序倒转析构时的情况更能体现规则的合理性。派生类析构函数体执行时派生类成员还活着但一旦进入基类析构派生类成员已经销毁。如果在基类析构里调用一个 virtual 函数而它碰巧解析为派生类版本这个函数几乎必然访问到已经销毁的派生类成员。等于在给一个拆除现场的人发图纸图纸却是大楼建成时的状态。条款九本质上保护的是对象生命周期的安全边界多态是一个只属于“完整对象”的特性处于构造或析构中间态的对象不具备这个资格。4. 需求还在virtual 不能用了那怎么办4.1 最机械也最可靠的做法向基类构造函数传参既然基类构造时拿不到派生类信息那就把派生类知道的信息主动传给基类class Transaction { public: enum class Type { Unknown, Buy, Sell }; explicit Transaction(Type t) : type_(t) { logTransaction(type_); // 不再依赖 virtual } virtual ~Transaction() default; private: Type type_; void logTransaction(Type t) const { switch (t) { case Type::Buy: std::cout Transaction: buy order\n; break; case Type::Sell: std::cout Transaction: sell order\n; break; default: std::cout Transaction: unknown type\n; break; } } }; class BuyTransaction : public Transaction { public: BuyTransaction() : Transaction(Type::Buy) {} };这种方式最直白把所有多变的部分压缩成参数传递。代价是如果派生类类型特别多switch 会膨胀而且基类需要对所有可能的日志形式有感知。但它的优势也很突出不依赖任何多态机制构造时一定能拿到正确信息。4.2 后置初始化接口两阶段构造的取舍另一种常见方案是把初始化逻辑从构造函数里剥离放到一个独立的初始化函数里由派生类构造完成后手动调用class Transaction { public: virtual ~Transaction() default; void init() { logTransaction(); // 此时对象已经完全构造多态生效 } virtual void logTransaction() const { std::cout Transaction: unknown type\n; } }; class BuyTransaction : public Transaction { public: BuyTransaction() { init(); } void logTransaction() const override { std::cout Transaction: buy order\n; } };这里的关键是init()是在BuyTransaction的构造函数体里调用的此时派生类部分已经完全构造虚表指针指向BuyTransaction虚调用才会正确绑定到派生类实现。但这个方法有隐患如果担心别人创建Transaction后忘了调init()就埋下了对象状态不完整的雷。所以它更适合init()是 protected 的情形外部只能用工厂函数创建对象工厂内部确保init()被调用。4.3 工厂函数 optional现代 C 的优雅改写把构造过程封装成静态工厂函数对象未初始化完成前不暴露给外部class Transaction { public: enum class Type { Unknown, Buy, Sell }; static std::optionalBuyTransaction CreateBuy() { BuyTransaction t; t.initialize(); // 对象完整后调多态 return t; } protected: Transaction() default; void initialize() { logTransaction(); } virtual void logTransaction() const 0; }; class BuyTransaction : public Transaction { public: void logTransaction() const override { std::cout Transaction: buy order\n; } };把默认构造函数设为 protected外部没法直接构造未初始化的对象。所有创建入口都走工厂函数工厂函数里先造出完整对象再调initialize()触发多态逻辑。用std::optional返回可以处理构造过程中可能的失败情况。这种方案在代码评审里通常会被夸“思路清晰”但也要注意它改变了对象的生命周期管理方式如果项目里已经有大量直接构造对象的代码迁移成本会比较高。4.4 静态多态CRTP 的另类解法如果“基类构造函数想要派生类信息”这个需求出现在编译期就能确定的地方可以考虑 CRTPCuriously Recurring Template Pattern。核心思路是把“派生类类型”作为模板参数传给基类在基类构造里直接调用派生类的静态逻辑template typename Derived class TransactionBase { public: TransactionBase() { Derived::logTransaction(); // 编译期绑定不依赖虚函数 } }; class BuyTransaction : public TransactionBaseBuyTransaction { public: static void logTransaction() { std::cout Transaction: buy order\n; } };这里logTransaction是静态成员函数不涉及虚表构造时可以直接调用。前提是它不需要访问对象实例的成员状态否则依然会遇到成员未初始化的问题。CRTP 适合日志类型标记、类型名注册这类只需要类型信息的场景实际工程中有不少这么用的。4.5 拷贝构造和赋值中的同类陷阱顺带提醒一句条款九同样适用于拷贝构造和拷贝赋值。很多热门搜索词里都有“拷贝构造函数调用时机”这里明确讲一下当基类的拷贝构造或拷贝赋值内部调用了 virtual 函数而这个基类对象正在作为派生类对象的子对象被拷贝时多态同样失效。C 的规则覆盖所有构造和析构语境不只是普通的默认构造函数。如果你希望在拷贝过程中保留派生类的行为同样要采用传参或后置初始化等方案。拷贝场景更复杂因为复制来源可能是个真正的派生类对象你需要先通过虚函数或 typeid 判断类型再决定如何构造新对象——这已经属于“原型模式”的范畴了。5. 实战中常见问题与排查技巧5.1 问题速查表问题现象常见原因排查方向基类构造里调 virtual派生态完全没有执行违反条款九动态绑定未启用检查调用链上是否有构造/析构函数直接或间接调用 virtual基类构造调用虚函数时程序崩溃纯虚函数没有基类实现或调用链访问了未初始化成员确认纯虚函数是否有定义检查是否尝试访问派生类成员析构时输出奇怪的默认日志基类析构调 virtual 解析到了基类版本改为析构前先由派生类传递日志参数或移除析构中的虚调用编译不过报 undefined reference to vtable基类构造调用纯虚函数必须移除该调用或给纯虚函数提供基类定义但仍不推荐后置初始化被漏掉对象状态不完整init()不是强制调用或被外部直接构造对象使用 protected 构造函数 工厂函数统一创建入口5.2 排查思路分享我遇到过最难缠的一次线上问题业务日志里的交易类型时不时变成 Unknown没有规律看不出是和哪种界面操作相关。后来翻了半天代码发现是有个同事在基类构造函数里调了一个refreshCache()而这个函数内部会调logTransaction()。日志本身没问题只是基类构造时所有派生类的日志都打成了 Unknown。单看调用点谁都不会想到问题根源在构造链的深处。排查这类问题我的经验是凡是在构造或析构阶段出现的多态异常先全局搜索构造函数体和析构函数体再看它们调用的每个普通成员函数里是否有 virtual 调用。以现在的 IDE 和静态分析工具很多可以直接在调用链上标记“在构造函数中调用虚函数”的警告但老项目里这种代码经常被编译选项或注释掩盖掉。5.3 实用的工程建议编译器警告别忽略主流编译器在开启告警后会对构造函数中调用虚函数给出提示。GCC 的-Weffc会给出相关建议Clang 的-Wdynamic-class-memaccess和静态分析器也会抓这类问题。代码评审重点看构造链凡是构造函数调用链上出现 virtual 字样评论里直接引用 Effective C 条款九不用多说废话。给基类构造函数安排唯一的退出点如果基类构造函数逻辑复杂只允许它在最后执行一个非虚的finishBaseInit()不要在中间插入任何可能变化的行为。日志系统最容易违反条款九交易、配置、数据上报这些领域天然有“构造时顺便记一笔日志”的需求但恰恰是这些需求最容易让人写出违规代码。宁可多传几个参数也不要赌多态在构造时生效。6. 写在最后这个条款给我的启发处理过几次构造成员里的虚调用问题后我个人对 C 的多态又多了一层敬畏。多态实现得再灵活终究有一个前提对象得先完整地活在某个类型之下。构造和析构阶段对象像一个还没组装完的机器每个零件都在就位或拆除过程中此时让机器按照最终形态去运转注定会出错。条款九不是限制而是在帮你避开那些表面优雅、实则脆弱的写法。最后再分享一个我在实际项目里的小技巧如果某个基类确实需要在构造期间完成一些“看起来像多态”的事情把这个需求当成一个普通的策略参数来设计而不是当成继承关系的一部分。传参、模板、工厂函数三个方案里优先选传参因为它最简单最不容易被后续维护者改坏。等代码演进到确实需要更大灵活性时再考虑 CRTP 或纯后期的多态初始化不要在一开始就把设计搞得花里胡哨。毕竟 C 里最容易出问题的代码往往就是那些看起来充分利用了语言特性、实际上却违背了生命周期规则的代码。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询