news 2026/6/10 13:30:39

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/6/10 13:29:11

小提琴尺寸怎么选?身高臂长精准匹配,实测好琴推荐

选对适配自身身形的小提琴尺寸&#xff0c;是学好小提琴的前置基础。尺寸适配不当&#xff0c;会直接导致持琴姿势变形、发力方式错误&#xff0c;不仅阻碍基本功养成、浪费大量练琴时间&#xff0c;长期下来还会引发肩颈、手部肌肉劳损。本文用通俗直白的方式&#xff0c;拆解…

作者头像 李华
网站建设 2026/6/10 13:28:08

震惊!专业铝箔地贴究竟选哪家?这答案你不能错过

在装修装饰领域&#xff0c;铝箔地贴因其美观、耐用等特点&#xff0c;成为众多消费者的选择。然而&#xff0c;市场上铝箔地贴品牌众多&#xff0c;究竟该选哪家&#xff0c;着实让不少人犯难。下面为您深度剖析&#xff0c;助您做出明智之选。市场乱象与用户痛点当前铝箔地贴…

作者头像 李华
网站建设 2026/6/10 13:28:07

2026世界杯揭幕战墨西哥VS南非东道主坐拥地利人和

2026美加墨世界杯首场揭幕战&#xff0c;将于北京时间6月12日凌晨3:00&#xff0c;在墨西哥城阿兹特克体育场正式打响&#xff0c;由东道主墨西哥坐镇主场&#xff0c;迎战时隔十六年重返世界杯赛场的南非队。这座三度承办世界杯揭幕战的传奇球场&#xff0c;将见证全新赛制世界…

作者头像 李华
网站建设 2026/6/10 13:26:07

老域名是什么?为什么SEO都喜欢老域名

老域名是什么&#xff1f;为什么SEO都喜欢老域名&#xff1f; 在网站建设和SEO优化领域&#xff0c;“老域名”一直是一个热门话题。很多站长在启动新项目时&#xff0c;往往会优先选择老域名&#xff0c;而不是直接注册一个全新的域名。那么&#xff0c;老域名究竟是什么&…

作者头像 李华
网站建设 2026/6/10 13:24:30

国内咨询公司盘点:部门协同搭建为何成为降本提效保障

民营企业规模化发展过程中&#xff0c;部门分工细化是必然趋势&#xff0c;但很多企业却陷入“分工即分家”的经营困境。销售、生产、采购、行政、财务等各部门各自为战&#xff0c;信息不通、资源不享、配合不畅&#xff0c;跨部门协作效率低下、内耗严重。前端销售接单与后端…

作者头像 李华
网站建设 2026/6/10 13:23:57

LeetCode 1584. 连接所有点的最小费用

问题 给定一个二维数组 points&#xff0c;其中 points[i] [xi, yi] 表示平面上的一个点。 连接两个点 [xi, yi] 和 [xj, yj] 的费用为它们之间的曼哈顿距离&#xff1a; |xi - xj| |yi - yj|要求把所有点都连接起来&#xff0c;并且任意两个点之间都可以通过若干条边互相到达…

作者头像 李华