当前位置: 首页 > news >正文

Effective C++ 条款09:绝不在构造和析构过程中调用 virtual 函数

Effective C++ 条款09:绝不在构造和析构过程中调用 virtual 函数

多态是 C++ 面向对象编程的核心特性之一,但有一个场景会让多态"失效"——那就是在构造函数和析构函数中调用 virtual 函数。这个看似反直觉的行为背后,有着深刻的语言设计原理。

一、一个令人困惑的例子

假设我们正在设计一个股票交易记录系统:

classTransaction{public:Transaction(){logTransaction();// 调用 virtual 函数!}virtualvoidlogTransaction()const{std::cout<<"Transaction base log\n";}};classBuyTransaction:publicTransaction{public:virtualvoidlogTransaction()constoverride{std::cout<<"BuyTransaction log\n";}};classSellTransaction:publicTransaction{public:virtualvoidlogTransaction()constoverride{std::cout<<"SellTransaction log\n";}};

现在,当我们创建一个BuyTransaction对象时:

BuyTransaction bt;

你期望的输出是什么?

BuyTransaction log

但实际输出是:

Transaction base log

发生了什么?为什么调用的是基类版本的logTransaction,而不是派生类重写的版本?

二、原理分析:构造期间的类型变化

2.1 对象的构造顺序

在 C++ 中,对象的构造遵循严格的顺序:

1. 分配内存 2. 调用基类构造函数 3. 设置 vptr 指向基类的 vtable 4. 执行基类构造函数体 5. 调用成员变量构造函数 6. 设置 vptr 指向派生类的 vtable 7. 执行派生类构造函数体

关键洞察:在基类构造函数执行期间,对象的类型是基类,而不是派生类。

2.2 vptr 的切换过程

阶段vptr 指向对象的"动态类型"
进入Transaction()Transaction的 vtableTransaction
执行Transaction()函数体Transaction的 vtableTransaction
进入BuyTransaction()BuyTransaction的 vtableBuyTransaction
执行BuyTransaction()函数体BuyTransaction的 vtableBuyTransaction

因此,当我们在Transaction()中调用logTransaction()时:

  1. 通过 vptr 查找 vtable
  2. vptr 指向的是Transaction的 vtable
  3. vtable 中logTransaction的条目指向Transaction::logTransaction
  4. 调用的是基类版本!

2.3 为什么语言要这样设计?

这个设计不是 bug,而是必要的选择。考虑如果允许调用派生类版本会发生什么:

classBuyTransaction:publicTransaction{public:BuyTransaction():price_(fetchPrice()){}virtualvoidlogTransaction()constoverride{std::cout<<"Buy price: "<<price_<<std::endl;}private:doubleprice_;};

如果在Transaction()中调用的logTransaction()下降到BuyTransaction::logTransaction()

  • price_还没有被初始化!
  • 访问未初始化的成员 = 未定义行为

C++ 的设计哲学是安全优先:在构造期间,对象被视为其当前正在构造的类型,以避免访问未初始化的派生类成员。

2.4 析构函数中的同样问题

析构过程是构造的逆过程:

1. 执行派生类析构函数体 2. 设置 vptr 指向基类的 vtable 3. 析构成员变量 4. 执行基类析构函数体 5. 设置 vptr 继续指向基类的 vtable 6. 调用基类析构函数 7. 释放内存
classTransaction{public:~Transaction(){logTransaction();// 同样调用的是 Transaction::logTransaction}virtualvoidlogTransaction()const{std::cout<<"Transaction base log\n";}};

在基类析构函数中,派生类部分已经被销毁了,此时如果调用派生类的 virtual 函数,同样会访问已销毁的成员。

三、更危险的场景:间接调用

有时候,virtual 函数的调用不是直接的,而是通过另一个函数间接发生的:

classTransaction{public:Transaction(){init();// 看起来安全?}voidinit(){// ... 一些初始化代码 ...logTransaction();// 间接调用了 virtual 函数!}virtualvoidlogTransaction()const=0;// 纯虚函数};

这种情况下,如果logTransaction是纯虚函数,某些编译器可能会在运行时检测到并终止程序。但更多情况下,这会导致未定义行为

四、正确的解决方案

4.1 方案一:使用非 virtual 函数 + 参数传递

将需要的信息通过参数传递给基类构造函数:

classTransaction{public:explicitTransaction(conststd::string&logInfo){logTransaction(logInfo);// 调用非 virtual 函数}voidlogTransaction(conststd::string&logInfo)const{// 记录日志Logger::log("Transaction: %s",logInfo.c_str());}};classBuyTransaction:publicTransaction{public:BuyTransaction(conststd::string&symbol,intquantity,doubleprice):Transaction(createLogInfo(symbol,quantity,price)),symbol_(symbol),quantity_(quantity),price_(price){}private:staticstd::stringcreateLogInfo(conststd::string&symbol,intquantity,doubleprice){return"Buy "+std::to_string(quantity)+" shares of "+symbol+" at "+std::to_string(price);}std::string symbol_;intquantity_;doubleprice_;};

4.2 方案二:延后初始化

如果必须在构造后执行某些操作,可以使用工厂方法或两阶段构造:

classTransaction{public:// 构造函数不做日志记录Transaction()=default;// 提供一个显式的初始化方法virtualvoidinitialize(){logTransaction();}virtualvoidlogTransaction()const{std::cout<<"Transaction base log\n";}};classBuyTransaction:publicTransaction{public:voidinitialize()override{// 先完成 BuyTransaction 特有的初始化price_=fetchPrice();// 然后调用基类的初始化(如果需要)Transaction::initialize();}voidlogTransaction()constoverride{std::cout<<"BuyTransaction log, price="<<price_<<std::endl;}private:doubleprice_=0.0;};// 使用工厂方法确保正确的初始化顺序std::unique_ptr<Transaction>createBuyTransaction(){autoptr=std::make_unique<BuyTransaction>();ptr->initialize();// 现在可以安全地调用 virtual 函数了returnptr;}

4.3 方案三:使用辅助函数(推荐)

将日志逻辑提取到独立的、非 virtual 的辅助函数中:

classTransaction{public:Transaction(){logTransactionImpl();// 非 virtual 辅助函数}virtualvoidlogTransaction()const{logTransactionImpl();}protected:// 派生类可以重写这个来提供自定义日志信息virtualstd::stringgetLogInfo()const{return"Base transaction";}private:voidlogTransactionImpl()const{Logger::log(getLogInfo());}};classBuyTransaction:publicTransaction{public:BuyTransaction(conststd::string&symbol,intqty):symbol_(symbol),quantity_(qty){}protected:std::stringgetLogInfo()constoverride{return"Buy "+std::to_string(quantity_)+" "+symbol_;}private:std::string symbol_;intquantity_;};

注意:这里getLogInfo()虽然也是 virtual 的,但它是在派生类构造函数之后才被调用的(通过logTransaction()),所以是安全的。如果在基类构造函数中直接调用getLogInfo(),仍然会有同样的问题。

五、实际应用场景

5.1 GUI 框架中的窗口初始化

classWidget{public:Widget(){// 错误:在构造函数中调用 virtual 函数// paint(); // 不要这样做!}virtualvoidpaint()const=0;};classButton:publicWidget{public:Button(conststd::string&label):label_(label){}voidpaint()constoverride{// 使用 label_ 绘制按钮drawRect();drawText(label_);}private:std::string label_;};

正确做法:

classWidget{public:Widget()=default;// 显式的初始化方法voidshow(){paint();// 现在安全了,因为对象已完全构造}virtualvoidpaint()const=0;};// 使用Buttonbtn("Click me");btn.show();// 在对象完全构造后调用 paint()

5.2 数据库连接池中的连接初始化

classDBConnection{public:DBConnection(conststd::string&connStr):connStr_(connStr){// 不要在这里调用 virtual 的 onConnect()doConnect();// 非 virtual 的基础连接逻辑}virtualvoidonConnect(){// 派生类可以重写,但在构造函数中不会下降到派生类}protected:voiddoConnect(){// 实际的数据库连接逻辑}std::string connStr_;};classMySQLConnection:publicDBConnection{public:MySQLConnection(conststd::string&host,intport):DBConnection(buildConnStr(host,port)){}voidonConnect()override{// MySQL 特有的连接后初始化setCharacterSet("utf8mb4");}private:staticstd::stringbuildConnStr(conststd::string&host,intport){returnhost+":"+std::to_string(port);}};

5.3 游戏开发中的角色创建

classCharacter{public:explicitCharacter(conststd::string&name):name_(name){// 不要在这里调用 virtual 的 onSpawn()}// 在游戏循环中,对象完全构造后调用voidspawn(){onSpawn();// 现在安全}virtualvoidonSpawn(){std::cout<<name_<<" spawned\n";}protected:std::string name_;};classWarrior:publicCharacter{public:Warrior(conststd::string&name):Character(name),weapon_("Sword"){}voidonSpawn()override{Character::onSpawn();std::cout<<"Equipped with "<<weapon_<<std::endl;}private:std::string weapon_;};

六、编译器的帮助

现代编译器通常会对在构造函数/析构函数中调用 virtual 函数发出警告:

classBase{public:Base(){foo();// GCC/Clang 可能警告:// "call to pure virtual function during construction"}virtualvoidfoo()=0;};

建议:开启编译器的所有警告(-Wall -Wextra),并视警告为错误(-Werror)。

七、总结

场景行为风险
构造函数中调用 virtual 函数调用当前正在构造的类的版本不调用派生类版本,逻辑错误
析构函数中调用 virtual 函数调用当前正在析构的类的版本可能访问已销毁的成员
间接调用(通过非 virtual 函数)同样不会下降更隐蔽,更难发现

请记住

  • 在构造和析构期间不要调用 virtual 函数,因为这类调用从不下降至 derived class。
  • 如果需要派生类提供信息给基类构造函数,使用辅助函数并将信息作为参数传递。
  • 考虑使用工厂方法或两阶段构造来确保 virtual 函数在对象完全构造后被调用。
  • 开启编译器警告,帮助发现这类问题。

理解构造函数和析构函数中 vptr 的变化规律,是掌握 C++ 对象模型的关键一步。这个规则看似限制了灵活性,实则是语言为了保护你免受未定义行为的伤害而设置的安全网。


参考阅读

  • 《Effective C++》第三版,Scott Meyers
  • 《Inside the C++ Object Model》,Stanley B. Lippman
  • C++ Core Guidelines: C.82
http://www.jsqmd.com/news/985928/

相关文章:

  • 温州佩安德家装316L不锈钢波纹水管选购指南:一文看懂如何选择
  • 6个月破百万,立刻AI给创业者上了一课
  • 在威尼斯遇到注单未同步一直提不了现解决的方法?
  • Paperxie 工科课题助力:AI 代码生成一站式搞定毕业论文程序源码
  • 3步轻松备份你的QQ空间历史说说:GetQzonehistory完整指南
  • 专业的新手矶钓滑漂竿哪家好
  • OpenSpec 迭代修改建议
  • 打造Harness最佳实践,华为云智果AgentArts企业级智能体平台破解智能体规模化落地难题
  • 2026年,武汉口碑好的全屋定制工厂究竟有哪些?带你一探究竟!
  • KK键盘 v4.0.2-快捷连发+聊天气泡+斗图,输入体验直接拉满
  • 如何在Windows电脑上告别笨重模拟器?APK安装器让你3分钟搞定安卓应用安装
  • 2026年制造企业如何通过AI搜索优化与短视频获客:河北工厂品牌全网推广实战指南 - 年度推荐企业名录
  • 2026年东莞松山湖装修公司怎么选?权威测评六家高口碑装修公司(附松山湖专属避坑指南) - liuminghui
  • 多类型数据库如何高效监控?运维监控实战落地指南
  • Ubuntu 虚拟机 Docker 与 MySQL 8.0.42 部署指南
  • 爽姐的装修日常
  • 马鞍山26年甄选名猫猫狗狗宠物店权威排行榜店铺推荐,靠谱宠物店联系方式推荐 - 谊识预商贸
  • 2026年温岭税务代理公司推荐 企赢税务智能财税服务 - 本地品牌推荐
  • 针筒银浆回收厂家哪家性价比高:综合报价与回收率深度测评 - 品牌2026
  • 进程异常退出,定位原因技巧
  • Windows安卓应用安装革命:APK Installer带你告别笨重模拟器
  • FRPP 管道:玻纤增强聚丙烯防腐管道的性能革新与工业应用 - 苏一塑业13914572689
  • 2026年天水制冷机组回收,揭秘商家背后的秘密!
  • 【征稿·桂林】第七届机械工程、智能制造与机电一体化学术会议(MEIMM 2026)
  • 核货宝加拿大版订货系统:助力华商简化订货流程,降低成本
  • 2026论文全流程终极榜单:10款降AIGC工具,查重降重+降AIGC一次通关
  • 收藏!AI时代程序员必看:如何升级技能,避免被淘汰?
  • 呼入机器人先接待,人工再介入:网易智企·云商的AI客服如何处理售后高峰?
  • 2026 梅州厨卫屋面地下室漏水瓷砖空鼓测评:吉修匠 99.8 分五星榜首 - 吉修匠
  • 并联机器人载带机哪个更专业