Effective C++读书笔记(6)

来源:岁月联盟 编辑:exp 时间:2012-02-04

新年了~忙着东奔西跑3天,是时候回归正常生活了……

 

 

条款08:别让异常逃离析构函数

Prevent exceptions from leavingdestructors

C++ 不禁止但不鼓励从析构函数引发异常。考虑:

class Widget {
public:
   ...
   ~Widget() { ... } // 假设这里可能吐出一个异常
};

void doSomething()
{
   std::vector<Widget> v;
   ...
} // v在这里被自动销毁

当 vector v 被析构时,它有责任析构它包含的所有 Widgets。但假设在那些调用期间,先后有两个Widgets抛出异常,对于 C++ 来说,这太多了。在两个异常同时存在的情况下,程序若不是结束执行就是导致不明确行为。在本例中将导致不明确行为,使用标准库的任何其他容器(如list,set)或TR1的任何容器甚至array,也会出现相同情况。C++ 不喜欢析构函数吐出异常。

如果你的析构函数需要执行一个可能失败而抛出一个异常的操作,该怎么办呢?假设使用一个class负责数据库连接,为了确保客户不会忘记在 DBconnection对象上调用 close(),一个合理的想法是创建一个用来管理DBConnection资源的类,并在其析构函数中调用close:

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

它允许客户像这样编程:

{ // 打开一个区块(block)
DBConn dbc(DBConnection::create());

    // 建立DBConnection并交给DBConn对象以便管理
... // 通过DBConn的接口使用DBConnection对象
} //在区块结束点,DBConn对象被销毁,因而自动为DBConnection对象调用close

只要调用 close 成功,一切都美好。但是如果这个调用导致一个异常,DBConn 的析构函数将传播那个异常,也就是允许它离开析构函数。这就产生了问题,因为析构函数抛出了一个烫手的山芋。

有两个主要的方法避免这个麻烦。

·    Terminatethe program:如果 close 抛出异常就终止程序,一般是通过调用 abort。

DBConn::~DBConn()
{
try { db.close(); }
   catch (...) {
        制作运转记录,记下对close的调用失败;
        std::abort();
   }
}

它有一个好处是:阻止异常从析构函数中传播出去(那会导致不明确的行为)。也就是说,调用 abort 可以预先制“不明确行为”于死地。

·    Swallowthe exception:吞下因调用close而发生的异常。在此例中将在第一种方法下去掉abort那句语句。

通常,将异常吞掉是个坏主意,因为它隐瞒了“某些动作失败”的重要信息!然而,有些时候,吞下异常比冒程序过早终止或不明确行为的风险更可取。程序必须能够在遭遇到一个错误并忽略之后还能继续可靠地运行,这才能成为一个可行的选择。

·    析构函数应该永不引发异常。如果析构函数调用了可能抛出异常的函数,析构函数应该捕捉所有异常,然后不传播它们或者终止程序。

 

以上方法的问题都在于两者无法对引起 close 抛出异常的情况做出回应。

一个更好的策略是重新设计 DBConn 的接口,以使客户有机会对可能发生的问题做出回应。

class DBConn {
public:
...

    void close() // 供客户使用的新函数
{
    db.close();
    closed = true;
}

    ~DBConn()
{
   if (!closed) {
   try {
      db.close(); // 关闭连接(如果客户不那么做的话)

   }
   catch (...) { // 如果关闭动作失败,记录下来并结束程序或吞下异常
        制作运转记录,记下对close的调用失败;
        ...
   }
}

    private:
    DBConnection db;
    bool closed;
};

这样把调用 close 的责任从 DBConn 的析构函数移交给 DBConn 的客户(同时在 DBConn 的析构函数中仍内含一个“双保险调用”)。如果某个操作可能在失败时抛出异常,而又存在某种需要必须处理该异常,那么这个异常必须来自析构函数以外的某个函数。这是因为析构函数)引发异常是危险的,永远都要冒着程序过早终止或 不明确行为的风险。在本例中,让客户自己调用 close 并不是强加给他们的负担,而是给他们一个处理错误的机会。他们可以忽略它,依靠 DBConn 的析构函数去调用 close。如果真有错误发生,close的确抛出异常而且DBConn吞下该异常或结束程序,客户没有立场抱怨,毕竟他们曾有机会第一手处理问题,而他们选择了放弃。

·    如果客户需要对某个操作函数运行期间抛出的异常做出反应,那么类应该提供一个普通函数(非析构函数)执行该操作。

 

条款09:绝不在构造和析构过程中调用virtual函数

Never call virtual functions duringconstruction or destruction

先概述重点:你不应该在构造或析构期间调用 virtual函数,因为这样的调用不会如你想象那样工作,而且会让你很郁闷。作为 Java 或 C# 程序员,也要更加注意本条款,因为这是C++与它们不相同的一个地方。

假设你有一套模拟股票交易的类继承体系,例如,购入、出售订单等。这样的交易一定要经过审计,所以每一个交易对象被创建,在一个审查日志中就需要创建一个相应的条目。下面是一个看起来似乎合理的解决问题的方法:

class Transaction { // 所有交易的基类
public:
Transaction();

virtual void logTransaction() const = 0; // 做出一份因类型不同而不同的日志记录
...
};

Transaction::Transaction() // 基类构造函数之实现
{
...
logTransaction(); // 最后动作是志记这笔交易
}

class BuyTransaction: public Transaction { //derived class
public:
virtual void logTransaction() const;
...
};

class SellTransaction: public Transaction {// derived class
public:
virtual void logTransaction() const;
...
};

考虑执行这行代码时会发生什么:

BuyTransaction b;

很明显一个 BuyTransaction 的构造函数会被调用,但是首先,一个 Transaction 的 构造函数必须先被调用,派生类对象中的基类成分先于派生类自身成分被构造之前构造。Transaction 的构造函数的最后一行调用 virtual函数 logTransaction,,被调用的 logTransaction 版本是在 Transaction 中的那一个,而不是 BuyTransaction 中的那一个,即使被创建的对象类型是 BuyTransaction。基类构造期间,virtual函数从来不会向下匹配到派生类。

更根本的原因:在一个派生类对象的基类构造期间,对象的类型是基类,而不是派生类。不仅 virtual函数会解析到基类,而且若使用到 runtime type information(运行时类型信息)的语言构件(例如,dynamic_cast和 typeid),也会将那个对象视为基类类型。本例中,当 Transaction 的 构造函数正打算初始化一个 BuyTransaction对象的基类部分时,该对象的类型是Transaction 。这样的对待是合理的:这个对象的 BuyTransaction专属部分还没有被初始化,所以最安全的做法是视它们不存在。对象在派生类构造函数开始执行前不会成为一个派生类对象。同样的道理也适用于析构函数。

在上面的示例代码中,Transaction 的构造函数造成了对一个 virtual函数的直接调用,这很明显而且容易看出违反本条款。这一违背是如此显见,以致一些编译器会给出一个关于它的警告(另一些则不会)。

在构造或析构期间调用 virtual函数的问题并不总是如此容易被察觉。如果 Transaction 有多个构造函数,每一个都必须完成一些相同的工作,为避免代码重复将共通的初始化代码,包括对 logTransaction 的调用,放入一个初始化函数中,叫做 init:

class Transaction {
public:
Transaction()
{ init(); } // 调用non-virtual...

virtual void logTransaction() const = 0;
...

private:
void init()
{
   ...
   logTransaction(); // 这里调用virtual!
}
};

这个代码在概念上和早先那个版本相同,但是它更阴险,因为一般来说它会躲过编译器和连接程序的抱怨。其实还是在构造函数内调用了virtual。避免这个问题的唯一办法就是确保你的构造函数或析构函数决不在被创建或析构的对象上调用 virtual函数,而它们所调用的所有函数也服从同样的约束。

如何确保在每一次 Transaction继承体系中的一个对象被创建时,都会调用 logTransaction 的正确版本呢?将 Transaction 中的 logTransaction 转变为一个 non-virtual函数,然后要求派生类构造函数将必要的信息传递给 Transaction 构造函数,而后那个函数就可以安全地调用 non-virtual的 logTransaction。如下:

class Transaction {
public:
    explicit Transaction(const std::string& logInfo);

       void logTransaction(conststd::string& logInfo) const;

       // 如今是个non-virtual函数
    ...
};

Transaction::Transaction(const std::string& logInfo)
{
    ...
    logTransaction(logInfo); //如今是个non-virtual函数
}

class BuyTransaction: public Transaction {
public:
    BuyTransaction( parameters )
    : Transaction(createLogString( parameters ))
    { ... } // 将log信息传递给基类构造函数
    ...

    private:
    static std::string createLogString(parameters );
};

换句话说,由于你不能在基类的构造过程中使用 virtual函数向下调用,你可以改为让派生类将必要的构造信息上传给基类构造函数作为补偿。

在此例中,注意 BuyTransaction 中那个 private static 函数 createLogString 的使用。使用一个辅助函数创建一个值传递给基类构造函数,通常比通过在成员初值列给基类它所需数据更加便利(也更加具有可读性)。将那个函数设置为static,就不会有偶然触及到一个新生的 BuyTransaction object对象的仍未初始化的数据成员的危险。

·    在构造或析构期间不要调用 virtual函数,因为这样的调用从不下降至派生类(比起当前执行构造函数和析构函数的那层)。

 
 摘自 pandawuwyj的专栏