《Effective C++》《构造/析构/赋值运算——8、别让异常逃离析构函数》

时间:2024-04-01 10:10:12

文章目录

  • 1、Terms 8:Prevent exceptions from leaving destructors
  • 2、面试相关
    • 2.1. 析构函数是否可以抛出异常?为什么?
    • 2.2. 如果析构函数抛出异常,会有什么后果?
    • 2.3. 如何避免析构函数抛出异常?
    • 2.4. 构造函数和析构函数在异常处理中的角色是什么?
    • 2.5. 如何确保析构函数的安全性?
  • 3、总结
  • 4、参考

1、Terms 8:Prevent exceptions from leaving destructors

C++ 并不禁止析构函数吐出异常,但它不鼓励你这样做。这是有理由的。考虑以下代码:

class Widget {
public:
	...
    ~Widget() { ... } //假设这个析构函数可能会抛出异常
};
 
int main() {
    std::vector<Widget> v;
    ...
    return 0;
}//v在这里自动销毁

当 vector v 被销毁,他有责任销毁其内含的所有 Widgets。假设 v 内有10个Widgets,那么在程序结束时会逐个释放这10个Widget对象。但是假设在释放第1个对象时,第1个Widget的析构函数中抛出了异常,并且没有对任何异常进行任何处理,此时程序就会中断。
如果你的析构函数必须执行一个动作,而该动作可能会在失败时抛出异常,该怎么办?我们通过一个例子进行说明:
先建立一个类负责连接数据库:

class DBConnection {
public:
    //该函数返回一个DBConnection对象
    static DBConnection create();
 
    //关闭数据库连接(失败会抛出异常)
    void close();
};

再建立另一个用来管理数据库对象:
下面通过数据库连接资源的类,并在其析构函数中调用 close。

//用来管理DBConnection对象
class DBConn {
public:
	...
    //确保数据库连接总是会被关闭
    ~DBConn() { 
    	db.close(); 
   	}
private:
    DBConnection db;
};

这样就便于客户写出这样的代码:

int main() {
    //建立一个DBConnection对象并交给DBConn管理,使用DBConn的接口管理DBConnection
    DBConn dbc(DBConnection::create());
 	...
    return 0;
}//程序结束时,DBConn对象被销毁,因此会自动为DBConnection对象调用close

如果调用close()调用成功的话那么就一切都好。如果close()函数调用出错(有异常),那么DBConn析构函数也会传播该异常,导致程序出错。
有两个方法可以解决这个问题:

  • 方法1:如果close函数抛出异常,就结束程序,可以通过调用abort完成
DBConn::~DBConn()
{
    try { db.close(); }
    catch (...) {
        //制作运作记录,记下对close的调用失败
        std::abort();
    }
}

如果程序在析构期间发生一个错误,那么“强迫程序结束”是一个合理的设置。

  • 方法2:忽略(吞掉)这个异常
DBConn::~DBConn() {
    try { 
    	db.close(); 
   	}
    catch (...) {
        /*此处还可以做一个记录,记下对close的调用失败,
          其他什么都不做
        */
    }
}

一般而言,忽略这个异常是个坏主意,因为忽略这个异常会造成不明确的行为和前面两个方法相比,还有一个更好的解决办法:
一般而言,忽略这个异常是个坏主意,因为忽略这个异常会造成不明确的行为一个更好的策略是重新设计DBConn接口,DBConn可以追踪所管理的DBConnection是否已经关闭:

  • 如果已经关闭就不做任何事情。
  • 如果还没关闭,并且抛出了异常,那么还是要使用到上面的两种解决方案。
class DBConn {
public:
	...
    DBConn::~DBConn()
    {
        if (!closed) {
            try { db.close(); }
            catch () {
                //此处还可以做一个记录,记下对close的调用失败
            }
        }
    } 
    void close() { // 供使客户使用的新函数
        db.close();
        closed = true;
    }
private:
    DBConnection db;
    bool closed;
};

提供一个完整可以编译的代码:

#include <iostream>  
  
// 假设有一个DBConnection类,这里仅声明,具体实现需要你自己提供  
class DBConnection {  
public:  
    void close() {  
        // 关闭数据库连接的逻辑  
        std::cout << "Closing database connection." << std::endl;  
    }  
};  
  
class DBConn {  
public:  
    // 构造函数初始化db和closed  
    DBConn() : db(), closed(false) {  
        // 初始化数据库连接  
        std::cout << "Initializing database connection." << std::endl;  
    }  
  
    // 析构函数确保在对象销毁时关闭数据库连接  
    ~DBConn() { 
        if(!closed){
            try{db.close();}
            catch(const std::exception& e){
             // 捕获异常并记录错误  
            std::cerr << "Error closing database connection: " << e.what() << std::endl; 
             // 这里可以选择抛出异常或记录错误并继续
            }
        }
    }  
    // 提供一个公共的close方法供客户使用  
    void close() {  
        db.close();
        closed = true;
    }  
  
private:  
    DBConnection db;  
    bool closed;  
};  
  
int main() {  
    DBConn conn;  
    // 使用数据库连接...  
  
    // 显式关闭数据库连接  
    conn.close();  
  
    // 当conn对象离开作用域时,析构函数会自动关闭数据库连接  
    return 0;  
}

总结:

  • 析构函数绝对不要抛出异常。如果一个被析构函数调用的函数可能抛出异常,析构函数应该捕获并处理该异常。
  • 如果客户需要对某个操作函数运行期间抛出的异常做出反应,那么类应该提供一个普通函数(而非在析构函数中)执行该操作。

2、面试相关

关于析构函数能否抛出异常,以下是一些相关的高频面试题:

2.1. 析构函数是否可以抛出异常?为什么?

  • 答案:析构函数不应该抛出异常。因为析构函数在对象生命周期结束时自动调用,如果它抛出异常,那么异常处理机制会变得复杂且难以管理。特别是当析构函数在异常处理过程中被调用时,如果它再次抛出异常,可能会导致程序崩溃。

2.2. 如果析构函数抛出异常,会有什么后果?

  • 答案:如果析构函数抛出异常,可能会导致资源泄露和其他未定义的行为。因为在析构函数中抛出的异常可能会中断其他重要的清理工作,如释放内存或关闭文件句柄等。此外,如果析构函数在异常处理中被调用,再次抛出异常会导致程序调用std::terminate(),从而终止执行。

2.3. 如何避免析构函数抛出异常?

  • 答案:可以通过在析构函数内部使用try-catch块来捕获并处理可能发生的异常,确保析构函数不会向外抛出异常。这样可以保持析构函数的异常安全性,并防止程序崩溃。

2.4. 构造函数和析构函数在异常处理中的角色是什么?

  • 答案:构造函数用于初始化对象的状态和成员变量,而析构函数用于清理对象的资源和执行必要的收尾操作。在异常处理中,如果构造函数抛出异常,对象的创建将失败,析构函数不会被调用。然而,如果异常发生在对象的使用过程中,析构函数将被调用以释放资源。因此,析构函数必须确保即使在异常情况下也能安全地释放资源。

2.5. 如何确保析构函数的安全性?

  • 答案:为了确保析构函数的安全性,应该避免在析构函数中执行可能抛出异常的操作。如果确实需要执行这样的操作,应该使用try-catch块来捕获并处理异常,确保析构函数不会向外抛出异常。此外,析构函数应该尽量简单和直接,只执行必要的清理工作,避免引入复杂的逻辑或调用可能抛出异常的方法。

3、总结

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

4、参考

4.1《Effective C++》