《Effective C++》《构造/析构/赋值运算——9、绝不在构造和析构过程中调用virtual函数》

时间:2024-04-07 19:15:12

文章目录

  • 1、Terms 9:Never call virtual functions during construction or destruction
    • 1.1为什么不要在构造、析构函数中调用 virtual 函数
      • 1.1.1经典错误
      • 1.1.2 隐藏错误
    • 1.2优化做法:
  • 2、面试相关
  • 3、总结
  • 4、参考

1、Terms 9:Never call virtual functions during construction or destruction

1.1为什么不要在构造、析构函数中调用 virtual 函数

1.1.1经典错误

假设你有个 class 继承体系,用来塑膜股市交易如买进卖出的订单等等。这样的交易一定要经过审计,所以每当创建一个交易对象,在审计日志中也需要创建一笔适当的记录。

#include <iostream>  
  
// 所有交易的基类  
class Transaction {  
public:  
    Transaction();  
    virtual ~Transaction() {} // 虚拟析构函数确保正确释放派生类资源  
    virtual void logTransaction() const = 0; // 日志记录,因类型不同,自身会有不同的操作  
  
    // ... 其他成员函数和成员变量 ...  
};  
  
// Transaction 类的构造函数实现  
Transaction::Transaction() {  
    // ... 构造函数的实现代码 ...  
    // 注意:通常不建议在基类的构造函数中调用虚函数,因为这将不会调用派生类的实现  
    // std::cout << "Transaction constructed" << std::endl; // 示例输出  
    logTransaction(); // 这将导致编译错误,因为 logTransaction 是纯虚函数  
}  
  
// 派生类 BuyTransaction  
class BuyTransaction : public Transaction {  
public:  
    BuyTransaction() : Transaction() {} // 确保基类构造函数被调用  
    virtual void logTransaction() const override {  
        // 记录此交易类型的日志  
        std::cout << "BuyTransaction logged" << std::endl;  
    }  
  
    // ... 其他成员函数和成员变量 ...  
};  
  
// 派生类 SellTransaction  
class SellTransaction : public Transaction {  
public:  
    SellTransaction() : Transaction() {} // 确保基类构造函数被调用  
    virtual void logTransaction() const override {  
        // 记录此交易类型的日志  
        std::cout << "SellTransaction logged" << std::endl;  
    }  
  
    // ... 其他成员函数和成员变量 ...  
};  
  
int main() {  
    // 创建派生类对象  
    BuyTransaction buyTx;  
    SellTransaction sellTx;  
  
    // 这里不能直接调用 Transaction 的 logTransaction,因为它是纯虚函数  
    // 但可以通过派生类对象调用  
    buyTx.logTransaction();  
    sellTx.logTransaction();  
  
    return 0;  
}

编译提示错误信息:

main.cpp: In constructor ‘Transaction::Transaction():
main.cpp:18:19: warning: pure virtualvirtual void Transaction::logTransaction() const’ called from constructor
   18 |     logTransaction(); // 这将导致编译错误,因为 logTransaction 是纯虚函数
      |     ~~~~~~~~~~~~~~^~
/usr/bin/ld: /tmp/ccNYEPdb.o: in function `Transaction::Transaction()':
main.cpp:(.text+0x26): undefined reference to `Transaction::logTransaction() const'
collect2: error: ld returned 1 exit status
报错原因:因为logTransaction函数在Transaction内是一个纯虚函数(pure virtual),
程序无法链接,因为连接器找不到必要的Transaction::logTransaction()的实现代码。

无疑,会有一个 BuyTransaction, SellTransaction构造函数被调用,但是 Transaction 构造函数一定会更早的调用,因为基类会先于派生类构造。
  当执行基类的构造函数时,基类的构造函数调用了虚函数logTransaction()
  由于C++多态的机制,我们实际上想让基类的构造函数调用的是派生类的虚函数logTransaction()(多态:使用一个基类的指针/引用指向于派生类,且派生类重写了基类的虚函数,当用该指针/引用调用虚函数时,调用的是派生类的虚函数)
  但是事实并非如此:当父类的构造函数执行,派生类此时还没有进行构造,因此基类中对logTransaction()的调用不会下降至派生类中,也就是说,此处我们在父类的构造函数中调用的实际上是基类的虚函数logTransaction(),但是由于基类中的logTransaction()函数是纯虚函数,因此程序编译错误。
  在派生类执行基类的构造函数时,派生类此时还未初始化。如果此时在基类的构造函数调用虚函数,调用的实际上是基类的虚函数,对虚函数的调用不会下降到派生类中。
用一句话总结就是:在 base class 构造期间,virtual 函数不再是 virtual 函数。
析构函数
不要在析构函数中调用virtual函数的原理也是相同的:
对象在析构时会先执行自己的析构函数,接着再去执行基类的析构函数
如果在基类的析构函数中调用了虚函数,那么调用的实际上也是基类的虚函数,而不会是派生类的(因为派生类已经在先前被释放了)

1.1.2 隐藏错误

为了避免代码重复的一个优秀做法是把共同的初始化代码(其中包括对logTransaction的调用)放进一个初始化函数init()内:1.1.1中是一个纯虚函数,当pure virtual函数被调用,大多执行系统会中止程序,但是如果是impure virtual函数并在Transaction()函数内部有一份实现代码,那么尽管你是derived的对象,调用的仍然是base class的实现。

class Transaction {
public:
    Transaction() { init(); }  // 初始化
 
    virtual void logTransaction() const = 0; //记录交易日志, 是个虚函数
private:
    void init() { 
        // 做一些初始化, 比如记录日志等
        logTransaction(); 
    }
};

1.2优化做法:

解决上述问题的关键,就是将base class内将logTransaction()函数改为non-virtual,然后要求derived class构造函数传递必要的信息给Transaction构造函数,从而更安全地调用non-virtual实现函数。

#include <string>  
#include <iostream>
using namespace std;
class Transaction {  
public:  
    // 注意:这里添加了分号  
    explicit Transaction(const std::string& logInfo) { logTransaction(logInfo); }  
    // 初始化日志信息  
    void logTransaction(const std::string& logInfo) const {  
        // 这里是日志记录的逻辑,例如打印到控制台或写入日志文件  
        // ...  
        std::cout << "Base Transaction constructed" << std::endl; // 示例输出 
        std::cout << "Base_construct —— "<< logInfo << std::endl; // 示例输出 
        
    }  
};  
class BuyTransaction : public Transaction {  
public:  
    // 假设BuyTransaction需要商品名称和价格作为参数  
    BuyTransaction(const std::string& itemName, double price)  
        : Transaction(createLogString(itemName, price)) {  
        // 这里可以添加BuyTransaction特有的初始化代码  
        std::cout << "BuyTransaction logged" << std::endl;  
        std::cout << "Buying: " << itemName << " at $" << price << std::endl; 
    }  
  
private:  
    static std::string createLogString(const std::string& itemName, double price) {  
        // 假设我们创建了一个日志字符串,包含了购买的信息  
        return "Buy: " + itemName + " at $" + std::to_string(price);  
    }  
};  
class SellTransaction : public Transaction {  
public:  
    // 假设SellTransaction需要商品名称和价格作为参数  
    SellTransaction(const std::string& itemName, double price)  
        : Transaction(createLogString(itemName, price)) {  
        // 这里可以添加SellTransaction特有的初始化代码  
        std::cout << "SellTransaction logged" << std::endl;
        std::cout << "Selling: " << itemName << " at $" << price << std::endl; 
    }  
private:  
    static std::string createLogString(const std::string& itemName, double price) {  
        // 假设我们创建了一个日志字符串,包含了售卖的信息  
        return "Sell: " + itemName + " at $" + std::to_string(price);  
    }  
};
int main() {  
    // 创建一个BuyTransaction对象  
    BuyTransaction buyTxn("Apple", 1.99);  
    // 创建一个SellTransaction对象  
    SellTransaction sellTxn("Orange", 2.49);  
    // 输出一些信息到控制台以确认对象已经被创建  
    std::cout << "BuyTransaction and SellTransaction objects have been created." << std::endl;  
    return 0;  
}

输出:

Base Transaction constructed
Base_construct —— Buy: Apple at $1.990000
BuyTransaction logged
Buying: Apple at $1.99
Base Transaction constructed
Base_construct —— Sell: Orange at $2.490000
SellTransaction logged
Selling: Orange at $2.49
BuyTransaction and SellTransaction objects have been created.

并且此处的createLogString()函数被设置为static函数是比较有意义的,因此静态函数不能调用非静态成员,因此就不会担心createLogString()函数中有未初始化的数据成员

2、面试相关

在构造/析构函数中使用虚函数是一个常见的面试问题,因为这里涉及到一些C++的特性和潜在的问题。以下是关于这个问题的五个高频面试题及其解答:

面试题1:在构造函数中能否调用虚函数?

解答:在构造函数中可以调用虚函数,但此时调用的不是子类覆盖的版本,而是基类自身的版本。这是因为在构造函数执行时,对象的类型还完全是基类的类型,子类部分还没有被构造出来,所以此时调用的虚函数是基类的版本。

面试题2:为什么在构造函数中调用虚函数通常不是一个好主意?

解答:在构造函数中调用虚函数可能导致预期之外的行为,因为此时调用的不是子类覆盖的版本。这可能导致逻辑错误或不符合设计初衷的行为。此外,如果在基类的构造函数中调用虚函数,而该虚函数在子类中又被重写为抛出异常,那么在构造子类对象时可能会抛出异常,这可能导致资源泄露或其他问题。

面试题3:析构函数中能否调用虚函数?

解答:在析构函数中可以调用虚函数,此时调用的是子类覆盖的版本(如果存在的话)。因为在析构函数执行时,对象已经是一个完整的对象,包括基类和子类部分,所以此时调用的虚函数会根据对象的实际类型来确定。

面试题4:析构函数中调用虚函数需要注意什么?

解答:在析构函数中调用虚函数时,需要确保虚函数的实现不会导致任何资源泄露或无效的内存访问。因为析构函数的主要任务是清理资源,如果虚函数的实现不当,可能会破坏这个过程。此外,如果虚函数在子类中被重写为抛出异常,那么在析构函数中调用该虚函数可能会导致程序异常终止,这是需要避免的。

面试题5:如何安全地在析构函数中调用虚函数?

解答:为了安全地在析构函数中调用虚函数,可以采取以下策略:

  1. 确保虚函数的实现是安全的,不会导致资源泄露或无效的内存访问。
  2. 避免在虚函数中抛出异常,特别是在析构函数中。
  3. 如果可能的话,考虑将需要在析构函数中执行的操作封装到另一个非虚函数中,并在基类的析构函数中调用该函数。这样可以确保无论对象的实际类型是什么,都会执行相同的操作。

通过遵循这些原则,可以更安全地在析构函数中调用虚函数,并避免潜在的问题。

3、总结

天堂有路你不走,地狱无门你自来

4、参考

4.1《Effective C++》