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的 vtable | Transaction |
执行Transaction()函数体 | Transaction的 vtable | Transaction |
进入BuyTransaction() | BuyTransaction的 vtable | BuyTransaction |
执行BuyTransaction()函数体 | BuyTransaction的 vtable | BuyTransaction |
因此,当我们在Transaction()中调用logTransaction()时:
- 通过 vptr 查找 vtable
- vptr 指向的是
Transaction的 vtable - vtable 中
logTransaction的条目指向Transaction::logTransaction - 调用的是基类版本!
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