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


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

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

    1.1.1经典错误

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

    #include   
      
    // 所有交易的基类  
    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;  
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56

    编译提示错误信息:

    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
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    报错原因:因为logTransaction函数在Transaction内是一个纯虚函数(pure virtual),
    程序无法链接,因为连接器找不到必要的Transaction::logTransaction()的实现代码。
    
    • 1
    • 2

    无疑,会有一个 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
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    1.2优化做法:

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

    #include   
    #include 
    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;  
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56

    输出:

    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.
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

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

    2、面试相关

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

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

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

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

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

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

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

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

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

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

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

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

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

    3、总结

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

    4、参考

    4.1《Effective C++》

  • 相关阅读:
    CANoe(持续更新修改...)
    2022/9/14(cf·div3)https://codeforces.com/contest/1729
    JVM 重要知识梳理
    解析 Python requests 库 POST 请求中的参数顺序问题
    Doris 5 处理Sentinel-1 生成干涉图 interferogram
    git LFS
    运维理想和现实,你是?
    大神方案|如何重写一个万行代码的类文件
    sentinel加密狗使用及规则配置
    使用Arduino开发板实现红外遥控器解码
  • 原文地址:https://blog.csdn.net/zwh1298454060/article/details/137202600