Effective C++ 条款09:绝不在构造和析构过程中调用 virtual 函数 Effective C 条款09绝不在构造和析构过程中调用 virtual 函数多态是 C 面向对象编程的核心特性之一但有一个场景会让多态失效——那就是在构造函数和析构函数中调用 virtual 函数。这个看似反直觉的行为背后有着深刻的语言设计原理。一、一个令人困惑的例子假设我们正在设计一个股票交易记录系统classTransaction{public:Transaction(){logTransaction();// 调用 virtual 函数}virtualvoidlogTransaction()const{std::coutTransaction base log\n;}};classBuyTransaction:publicTransaction{public:virtualvoidlogTransaction()constoverride{std::coutBuyTransaction log\n;}};classSellTransaction:publicTransaction{public:virtualvoidlogTransaction()constoverride{std::coutSellTransaction 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()时通过 vptr 查找 vtablevptr 指向的是Transaction的 vtablevtable 中logTransaction的条目指向Transaction::logTransaction调用的是基类版本2.3 为什么语言要这样设计这个设计不是 bug而是必要的选择。考虑如果允许调用派生类版本会发生什么classBuyTransaction:publicTransaction{public:BuyTransaction():price_(fetchPrice()){}virtualvoidlogTransaction()constoverride{std::coutBuy 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::coutTransaction base log\n;}};在基类析构函数中派生类部分已经被销毁了此时如果调用派生类的 virtual 函数同样会访问已销毁的成员。三、更危险的场景间接调用有时候virtual 函数的调用不是直接的而是通过另一个函数间接发生的classTransaction{public:Transaction(){init();// 看起来安全}voidinit(){// ... 一些初始化代码 ...logTransaction();// 间接调用了 virtual 函数}virtualvoidlogTransaction()const0;// 纯虚函数};这种情况下如果logTransaction是纯虚函数某些编译器可能会在运行时检测到并终止程序。但更多情况下这会导致未定义行为。四、正确的解决方案4.1 方案一使用非 virtual 函数 参数传递将需要的信息通过参数传递给基类构造函数classTransaction{public:explicitTransaction(conststd::stringlogInfo){logTransaction(logInfo);// 调用非 virtual 函数}voidlogTransaction(conststd::stringlogInfo)const{// 记录日志Logger::log(Transaction: %s,logInfo.c_str());}};classBuyTransaction:publicTransaction{public:BuyTransaction(conststd::stringsymbol,intquantity,doubleprice):Transaction(createLogInfo(symbol,quantity,price)),symbol_(symbol),quantity_(quantity),price_(price){}private:staticstd::stringcreateLogInfo(conststd::stringsymbol,intquantity,doubleprice){returnBuy 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::coutTransaction base log\n;}};classBuyTransaction:publicTransaction{public:voidinitialize()override{// 先完成 BuyTransaction 特有的初始化price_fetchPrice();// 然后调用基类的初始化如果需要Transaction::initialize();}voidlogTransaction()constoverride{std::coutBuyTransaction log, priceprice_std::endl;}private:doubleprice_0.0;};// 使用工厂方法确保正确的初始化顺序std::unique_ptrTransactioncreateBuyTransaction(){autoptrstd::make_uniqueBuyTransaction();ptr-initialize();// 现在可以安全地调用 virtual 函数了returnptr;}4.3 方案三使用辅助函数推荐将日志逻辑提取到独立的、非 virtual 的辅助函数中classTransaction{public:Transaction(){logTransactionImpl();// 非 virtual 辅助函数}virtualvoidlogTransaction()const{logTransactionImpl();}protected:// 派生类可以重写这个来提供自定义日志信息virtualstd::stringgetLogInfo()const{returnBase transaction;}private:voidlogTransactionImpl()const{Logger::log(getLogInfo());}};classBuyTransaction:publicTransaction{public:BuyTransaction(conststd::stringsymbol,intqty):symbol_(symbol),quantity_(qty){}protected:std::stringgetLogInfo()constoverride{returnBuy std::to_string(quantity_) symbol_;}private:std::string symbol_;intquantity_;};注意这里getLogInfo()虽然也是 virtual 的但它是在派生类构造函数之后才被调用的通过logTransaction()所以是安全的。如果在基类构造函数中直接调用getLogInfo()仍然会有同样的问题。五、实际应用场景5.1 GUI 框架中的窗口初始化classWidget{public:Widget(){// 错误在构造函数中调用 virtual 函数// paint(); // 不要这样做}virtualvoidpaint()const0;};classButton:publicWidget{public:Button(conststd::stringlabel):label_(label){}voidpaint()constoverride{// 使用 label_ 绘制按钮drawRect();drawText(label_);}private:std::string label_;};正确做法classWidget{public:Widget()default;// 显式的初始化方法voidshow(){paint();// 现在安全了因为对象已完全构造}virtualvoidpaint()const0;};// 使用Buttonbtn(Click me);btn.show();// 在对象完全构造后调用 paint()5.2 数据库连接池中的连接初始化classDBConnection{public:DBConnection(conststd::stringconnStr):connStr_(connStr){// 不要在这里调用 virtual 的 onConnect()doConnect();// 非 virtual 的基础连接逻辑}virtualvoidonConnect(){// 派生类可以重写但在构造函数中不会下降到派生类}protected:voiddoConnect(){// 实际的数据库连接逻辑}std::string connStr_;};classMySQLConnection:publicDBConnection{public:MySQLConnection(conststd::stringhost,intport):DBConnection(buildConnStr(host,port)){}voidonConnect()override{// MySQL 特有的连接后初始化setCharacterSet(utf8mb4);}private:staticstd::stringbuildConnStr(conststd::stringhost,intport){returnhost:std::to_string(port);}};5.3 游戏开发中的角色创建classCharacter{public:explicitCharacter(conststd::stringname):name_(name){// 不要在这里调用 virtual 的 onSpawn()}// 在游戏循环中对象完全构造后调用voidspawn(){onSpawn();// 现在安全}virtualvoidonSpawn(){std::coutname_ spawned\n;}protected:std::string name_;};classWarrior:publicCharacter{public:Warrior(conststd::stringname):Character(name),weapon_(Sword){}voidonSpawn()override{Character::onSpawn();std::coutEquipped 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. LippmanC Core Guidelines: C.82