条款08:别让异常逃离析构函数
析构函数一般情况下不应抛出异常,因为很大可能发生各种未定义的问题,包括但不限于内存泄露、程序异常崩溃、所有权被锁死等。
一个直观的解释:析构函数是一个对象生存期的最后一刻,负责许多重要的工作,如线程,连接和内存等各种资源所有权的归还。如果析构函数执行期间某个时刻抛出了异常,就说明抛出异常后的代码无法再继续执行,这是一个非常危险的举动——因为析构函数往往是为类对象兜底的,甚至是在该对象其他地方出现任何异常的时候,析构函数也有可能会被调用来给程序擦屁股。在上述场景中,如果在一个异常环境中执行的析构函数又抛出了异常,很有可能会让程序直接崩溃,这是每一个程序员都不想看到的。
话说回来,如果某些操作真的很容易抛出异常,如资源的归还等,并且你又不想把异常吞掉,那么就请把这些操作移到析构函数之外,提供一个普通函数做类似的清理工作,在析构函数中只负责记录,我们需要时刻保证析构函数能够执行到底。
为了实现 RAII(创建一个用来管理DBConnection资源的class,并在其析构函数中调用close()),我们通常会将对象的销毁方法封装在析构函数中,如下例子:
class DBConnection{
public:
static BDConnection create();
void close(); //关闭连接,失败时抛出异常
};
class DBConn {
public:
...
~DBConn() {
db.close(); // 该函数可能会抛出异常
}
private:
DBConnection db;
};
//开启一个区块,建立DBConnection对象并交给DBConn对象管理
//通过DBConn的接口使用DBConnection对象
//在区块结束时,DBConn对象被销毁,自动调用DBConnection对象的close()
{
DBConn dbc(DBConnection::create());
...
}
但这样我们就需要在析构函数中完成对异常的处理,以下是几种常见的做法:
第一种:杀死程序(通常调用abort完成);
DBConn::~DBConn() {
try { db.close(); }
catch (...) {
// 记录运行日志,以便调试
std::abort();
}
}
第二种:直接吞下异常不做处理,但这种做法不被建议;
DBConn::~DBConn() {
try { db.close(); }
catch (...) {
// 记录运行日志,以便调试
}
}
第三种:重新设计接口,将异常的处理交给客户端完成;
class DBConn {
public:
...
void close() {
db.close();
closed = true;
}
~DBConn() {
if (!closed) {
try {
db.close();
}
catch(...) {
// 处理异常
}
}
}
private:
DBConnection db;
bool closed;
};
在这个新设计的接口中,我们提供了close
函数供客户手动调用,这样客户也可以根据自己的意愿处理异常;若客户忘记手动调用,析构函数才会自动调用close
函数。
当一个操作可能会抛出需要客户处理的异常时,将其暴露在普通函数而非析构函数中是一个更好的选择。
条款09:绝不在构造和析构过程中调用virtual
函数。
在多态环境中,我们需要重新理解构造函数和析构函数的意义(在创建派生类对象时,基类的构造函数永远会早于派生类的构造函数被调用,而基类的析构函数永远会晚于派生类的析构函数被调用),这两个函数在执行过程中,涉及到了对象类型从基类到子类,再从子类到基类的转变。
一个子类对象开始创建时,首先调用的是基类的构造函数,在调用子类构造函数之前,该对象将一直保持着“基类对象”的身份而存在,自然在基类的构造函数中调用的虚函数——将会是基类的虚函数版本,在子类的构造函数中,原先的基类对象变成了子类对象,这时子类构造函数里调用的是子类的虚函数版本。这是一件有意思的事情,这说明在构造函数中虚函数并不是虚函数,在不同的构造函数中,调用的虚函数版本并不同,因为随着不同层级的构造函数调用时,对象的类型在实时变化。那么相似的,析构函数在调用的过程中,子类对象的类型从子类退化到基类。
因此,如果你指望在基类的构造函数中调用子类的虚函数,那就趁早打消这个想法好了。但很遗憾的是,你可能并没有意识到自己做出了这样的设计,例如将构造函数的主要工作抽象成一个init()
函数以防止不同构造函数的代码重复是一个很常见的做法,但是在init()
函数中是否调用了虚函数,就要好好注意一下了,如下面的代码,同样的情况在析构函数中也是一样。
间接调用虚函数是一个比较难以发现的危险行为,需要尽量避免:
class Transaction {
public:
Transaction() { Init(); }
virtual void LogTransaction() const = 0;
private:
void Init(){
...
LogTransaction(); // 此处间接调用了虚函数!
}
};
如果想要基类在构造时就得知派生类的构造信息,推荐的做法是在派生类的构造函数中将必要的信息向上传递给基类的构造函数:
class Transaction {
public:
explicit Transaction(const std::string& logInfo);
void LogTransaction(const std::string& logInfo) const;
...
};
Transaction::Transaction(const std::string& logInfo) {
LogTransaction(logInfo); // 更改为了非虚函数调用
}
class BuyTransaction : public Transaction {
public:
BuyTransaction(...)
: Transaction(CreateLogString(...)) { ... } // 将信息传递给基类构造函数
...
private:
static std::string CreateLogString(...);
}
注意此处的CreateLogString
是一个静态成员函数,这是很重要的,因为静态成员函数可以确保不会使用未完成初始化的成员变量。
条款10:令operator =返回一个reference to *this
简单来说:这样做可以让你的赋值操作符实现“连等”的效果(链式法则):
x = y = z = 10;
虽然并不强制执行此条款,但为了实现连锁赋值,大部分时候应该这样做:
class Widget {
public:
Widget& operator+=(const Widget& rhs) { // 这个条款适用于
... // +=, -=, *= 等等运算符
return *this;
}
Widget& operator=(int rhs) { // 即使参数类型不是 Widget& 也适用
...
return *this;
}
};
在设计接口时一个重要的原则是,让自己的接口和内置类型相同功能的接口尽可能相似,所以如果没有特殊情况,就请让你的赋值操作符的返回类型为ObjectClass&
类型并在代码中返回*this
。