您的位置:首页 > 编程语言 > C语言/C++

More Effective C++ 读书摘要(三、异常)Item9 - 15

2009-04-11 20:38 573 查看
为什么一定要使用异常?答案很简单:异常不能被忽略。

Item9.使用析构函数防止资源泄漏:
通过智能指针auto_prt将清除代码放入其析构函数里。核心部分如下:

template<class T>
class auto_ptr {
public:
    auto_ptr(T *p=0): ptr(p) {}
    ~auto_ptr() {delete ptr;}
    
private:
    T *ptr;
};


(因为auto_ptr的析构函数使用的是用于删除单个对象的delete,所以它不能用于指向对象数组的指针。这时个用vector代替array可能是更好的选择。)

auto_ptr背后的思想可以用于所有防止在异常被抛出时动态分配的资源泄漏问题,如下面的GUI程序:

class WindowHandle {
public:
    WindowHandle(WINDOW_HANDLE handle): w(handle) {}
    ~WindowHandle() {destroyWindow(w);}
    
    operator WINDOW_HANDLE() {return w;}    //*
    
private:
    WINDOW_HANDLE w;
    //以下明确禁止掉赋值和拷贝,Item28有更flexible的实现
    WindowHandle(const WindowHandle&);
    WindowHandle& operator=(const WindowHandle&);
};


Item10. 防止构造函数里的资源泄漏
C++只销毁构造完全的对象,即其构造函数被完全执行的对象。所以如果构造函数运行的过程中抛出了一个异常,那对象的析构函数将不会被调用。
先看非静态成员:

class BookEntry {
public:
    BookEntry(const string& name,
              const string& address = "",
              const string& imageFileName = "",
              const string& audioClipFileName = "");
    ~BookEntry();
    ...
private:
    string theName;
    string theAddress;
    list<PhoneNumber> thePhones;
    Image *theImage;
    AudioClip *theAudioClip;
    
    void cleanup();     //公共清除语句
};

void BookEntry::cleanup()
{//为了防止构造函数与析构函数中的清除语句重复而提取成一个公共函数
    delete theImage;
    delete theAudioClip;
}

BookEntry::BookEntry(const string& name,
                     const string& address = "",
                     const string& imageFileName = "",
                     const string& audioClipFileName = "")
:theName(name), theAddress(address), theImage(0), theAudioClip(0)
{
    try {
        ...//给theImage和theAudioClip设置
    }
    catch(...) {
        cleanup();
        throw;      //让调用者得知异常
    }
}

BookEntry::~BookEntry() 
{
    cleanup();
}


但是如果让theImage和theAudioClip都成为常量指针:

class BookEntry {
    public:
        ...
    private:
        ...
        Image * const theImage;
        AudioClip * const theAudioClip;
};


则只能通过成员初始化列表对其进行初始化。于是不能再在构造函数中使用try, catch块来解决这个问题了。
这时可使用auto_ptr来帮忙:

class BookEntry {
    public:
        ...
    private:
        ...
        const auto_ptr<Image> theImage;
        const auto_ptr<AudioClip> theAudioClip; 
        //注意const与前面位置的不同
};


现在的初始化形式为:

BookEntry::BookEntry(const string& name,
                     const string& address = "",
                     const string& imageFileName = "",
                     const string& audioClipFileName = "")
:theName(name), theAddress(address), 
 theImage(imageFileName != "" ? new Image(imageFileName) : 0),      //注意这里new对象来赋值初始化的用法
 theAudioClip(audioClipFileName != "" ? new AudioClip(audioClipFileName : 0)
{}      //构造函数与析构函数变得极为简单

BookEntry::~BookEntry() {}


小结:如果把那些声明为指针的类成员替换成它们相应的auto_ptr对象(thy:在Effective C++ 3rd中已经进一步建议用std::tr1::shared_ptr),在发生异常的时候构造函数就可以避免资源泄漏,而且免去了在析构函数中手工释放资源的必要。使用它们不仅使程序让人更容易理解,而且使程序在发生异常的时候更为健壮。

Item11. 阻止异常传递到析构函数以外:
阻止异常传递到析构函数以外以两个很好的理由。首先,在异常传递进行到堆栈解开(stack-unwinding)的过程中,防止terminate被调用,从而防止程序被当掉。第二它能帮助确保析构函数总能完成我们希望它做的所有事情,比如后面可能的endTransaction。

Session::~Session() 
{ 
    try {
        logDestruction(this);
    }
    catch(...) {}
    endTransaction();       //如果有的话
}


表面上看catch块什么也没做,但它阻止了logDestruction抛出的异常传递到Session的析构函数外面。



Item12. 理解抛出异常与传递参数或者调用虚函数之间的不同:
一、传递方式不同

①你调用函数时,程序的控制权最终还会返回到函数的调用处(除非函数没有正常返回),但是当你抛出一个异常时,控制权永远不会回到抛出异常的地方。所以不论通过传值捕获异常还是通过引用捕获(不能通过指针捕获这个异常,因为类型不匹配)都将进行拷贝操作,也就说传递到catch子句中的是一份拷贝。

当异常对象被拷贝时,拷贝操作是由对象的拷贝构造函数完成的。该拷贝构造函数是对象的静态类型(static type)所对应类的拷贝构造函数,而不是对象的动态类型(dynamic type)对应类的拷贝构造函数。即不再有多态性:

class Widget { ... };
class SpecialWidget: public Widget { ... };
void passAndThrowWidget()
{
  SpecialWidget localSpecialWidget;
  ...
  Widget& rw = localSpecialWidget;      //rw引用SpecialWidget
  throw rw;                             //它抛出一个类型为Widget的异常,而非SpecialWidget
}


②比较下面两个catch块:

catch (Widget& w)                 // 捕获Widget异常
{
  ...                             // 处理异常
  throw;                          // 重新抛出异常,让它
}                                 // 继续传递
catch (Widget& w)                 // 捕获Widget异常
{
  ...                             // 处理异常
  throw w;                        // 传递被捕获异常的
}                                 // 拷贝


这两个catch块的差别在于第一个catch块中重新抛出的是当前捕获的异常,而第二个catch块中重新抛出的是当前捕获异常的一个新的拷贝。第一个块中重新抛出的是当前异常(current exception),无论它是什么类型。特别是如果这个异常开始就是做为SpecialWidget类型抛出的,那么第一个块中传递出去的还是SpecialWidget异常,即使w的静态类型(static type)是Widget。这是因为重新抛出异常时没有进行拷贝操作。第二个catch块重新抛出的是新异常,类型总是Widget,因为w的静态类型(static type)是Widget。所以一般情况下应该使用throw;效率也更高。



③比较下面三个catch块:
catch (Widget w) ... //通过传值捕获异常,会两次创建被抛出对象的拷贝
catch (Widget& w) ... //通过传递引用捕获,会一次创建被抛出对象的拷贝
catch (const Widget& w) ... //通过传递指向const的引用,会一次创建被抛出对象的拷贝



通过指针抛出异常与通过指针传递参数是相同的。不论哪种方法都是一个指针的拷贝被传递。只要记住不要抛出一个指向局部对象的指针,因为当异常离开局部变量的生存空间时,该局部变量已经被释放。Catch子句将获得一个指向已经不存在的对象的指针。



二、类型匹配的过程不同
如标准库中double sqrt(double); // from <cmath> or <math.h>
我们能这样计算一个整数的平方根,如下所示:
int i;
double sqrtOfi = sqrt(i);
但如下的try...catch却不能如你相像地工作:

void f(int value)
{
  try {
    if (someFunction()) {      
    //如果 someFunction()返回真,抛出一个整形值
      throw value;             
    ...
    }
  }
  catch (double d) {          
  //只处理double类型的异常,前面throw的int值永远不会在这里被catch
    ...  
  }
  ...
}


不过在catch子句中进行异常匹配时可以进行两种类型转换。第一种是继承类与基类间的转换。一个用来捕获基类的catch子句也可以处理派生类类型的异常:
catch (runtime_error) ... // can catch errors of type
catch (runtime_error&) ... // runtime_error,
catch (const runtime_error&) ... // range_error, or
// overflow_error

catch (runtime_error*) ... // can catch errors of type
catch (const runtime_error*) ... // runtime_error*,
// range_error*, or
// overflow_error*
(range_error和overflow_error继承自runtime_error,而runtime_error又继承自exception)
第二种是允许从一个类型化指针(typed pointer)转变成无类型指针(untyped pointer),所以带有const void* 指针的catch子句能捕获任何类型的指针类型异常:

catch (const void*) ... //捕获任何指针类型异常

三、处理哲学不同
异常处理的策略是”最先匹配first fit“,而虚函数是”最优匹配best fit“(被调用的函数是属于离调用此函数的对象的动态类型最相近的类的)。
try {
  ...
}
catch (logic_error& ex) {              // 这个catch块 将捕获
  ...                                  // 所有的logic_error
}                                      // 异常, 包括它的派生类
 
catch (invalid_argument& ex) {         // 这个块永远不会被执行
  ...        //因为所有的invalid_argument异常 都被上面的catch子句捕获。
}

(invalid_argument继承自logic_error)
因此:不要把处理基类异常的catch子句放在处理派生类异常的catch子句的前面。
try {
  ...
}
catch (invalid_argument& ex) {          //处理 invalid_argument
  ...                                   //异常
}
catch (logic_error& ex) {               //处理所有其它的
  ...                                   //logic_errors异常
}


Item13.通过引用捕获异常:
捕获异常有三种方式:
通过指针(by pointer)

在理论上这种方法的实现对于这个过程来说是效率最高的。因为在传递异常信息时,只有采用通过指针抛出异常的方法才能够做到不拷贝对象

void doSomething()
{
  try {
    someFunction();               // 抛出一个 exception*
  }
  catch (exception *ex) {         // 捕获 exception*;
    ...                           // 没有对象被拷贝
  }
}


但为了能让程序正常运行,程序员定义异常对象时必须确保当程序控制权离开抛出指针的函数后,对象还能够继续生存,即全局与静态对象。
另一种抛出指针的方法是在建立一个堆对象(new heap object),但这会引发另一个问题:没办法知道异常对象该不该删除。通过指针捕获异常也不符合C++语言本身的规范。四个标准的异常――bad_alloc,bad_cast,bad_typeid和bad_exception都不是指向对象的指针,所以你必须通过值或引用来捕获它们。



通过传值(by value)
可以消除①所带来的问题,但是当它们被抛出时系统将对异常对象拷贝两次。而且它会产生slicing problem,即派生类的异常对象被做为基类异常对象捕获时,那它的派生类行为就被切掉了(sliced off)。这样的sliced对象实际上是一个基类对象:它们没有派生类的数据成员,而且当调用它们的虚拟函数时,系统解析后调用的是基类对象的函数。

void someFunction()                 // 抛出一个 validation异常
{         
  ...
  if (a validation 测试失败) {
    throw Validation_error();
  }
...
}
 
void doSomething()
{
  try {
    someFunction();                 //抛出 validation异常
  }               
  catch (exception ex) {            //捕获所有标准异常类或它的派生类
    cerr << ex.what();              //调用 exception::what(),而不是Validation_error::what()
    ...                   
  }   
}


通过引用(by reference)
通过引用捕获异常能使你避开上述所有问题。不象通过指针捕获异常,这种方法不会有对象删除的问题而且也能捕获标准异常类型。也不象通过值捕获异常,这种方法没有slicing problem,而且异常对象只被拷贝一次。

Item14. 审慎地使用异常规格:
注:异常规格即指定可能抛出的异常类型
编译器在编译时有时能够检测到异常规格的不一致。而且如果一个函数抛出一个不在异常规格范围里的异常,系统在运行时能够检测出这个错误,然后一个特殊函数unexpected将被自动地调用。



函数unexpected缺省的行为是调用函数terminate,而terminate缺省的行为是调用函数abort,所以一个违反异常规格的程序其缺省的行为就是halt(停止运行)。在激活的stack frame中的局部变量没有被释放,因为abort在关闭程序时不进行这样的清除操作。对异常规格的触犯变成了一场并不应该发生的灾难。



例如:函数f1没有声明异常规格,这样的函数就可以抛出任意种类的异常:
extern void f1(); // 可以抛出任意的异常
假设有一个函数f2通过它的异常规格来声明其只能抛出int类型的异常:
void f2() throw(int);
f2调用f1是非常合法的,即使f1可能抛出一个违反f2异常规格的异常:

void f2() throw(int)
{
  ...
  f1();          // 即使f1可能抛出不是int类型的异常,这也是合法的。
  ...
}


最终会导致程序的终结。(thy:在VC2005下实验未被终结
①一种好的避免调用unexpected函数的方法是避免在带有类型参数的模板内使用异常规格。

// a poorly designed template wrt exception specifications
template<class T>
bool operator==(const T& lhs, const T& rhs) throw()
{
  return &lhs == &rhs;
}


这个模板包含的异常规格表示模板生成的函数不能抛出异常。但是事实可能不会这样,因为opertor&能被一些类型对象重载。如果被重载的话,当调用从operator==函数内部调用opertor&时,opertor&可能会抛出一个异常,这样就违反了我们的异常规格,使得程序控制跳转到unexpected。
几乎不可能为一个模板提供一个有意义的异常规格,因为模板总是采用不同的方法使用类型参数。解决方法只能是模板和异常规格不要混合使用。



②能够避免调用unexpected函数的第二个方法是如果在一个函数内调用其它没有异常规格的函数时应该去除这个函数的异常规格。但是实际中容易被忽略,比如允许用户注册一个回调函数:

typedef void (*CallBackPtr)(int eventXLocation,
                            int eventYLocation,
                            void *dataToPassBack);

void makeCallBack(int eventXLocation, int eventYLocation) const throw()
{func();}


如果在makeCallBack内部使用了回调函数func,则无法知道func会抛出什么类型的异常。
解决方案是在CallBackPtr typedef中采用更严格的异常规格:

typedef void (*CallBackPtr)(int eventXLocation,
                            int eventYLocation,
                            void *dataToPassBack) throw();




③避免调用unexpected的第三个方法是处理系统本身抛出的异常。这些异常中最常见的是bad_alloc,当内存分配失败时它被operator new 和operator new[]抛出。如果你在函数里使用new操作符,你必须为函数可能遇到bad_alloc异常作好准备。

也就是说有时直接处理unexpected异常比防止它们被抛出要简单。例如你正在编写一个软件,精确地使用了异常规格,但是你必须从没有使用异常规格的程序库中调用函数,要防止抛出unexpected异常是不现实的,因为这需要改变程序库中的代码。

★虽然防止抛出unexpected异常是不现实的,但是C++允许你用其它不同的异常类型替换unexpected异常,你能够利用这个特性。例如你希望所有的unexpected异常都被替换为UnexpectedException对象。你能这样编写代码:

class UnexpectedException {};          //所有的unexpected异常对象被
                                       //替换为这种类型对象
void convertUnexpected()               //如果一个unexpected异常被
{                                      //抛出,这个函数被调用
  throw UnexpectedException();  
}


通过用convertUnexpected函数替换缺省的unexpected函数,来使上述代码开始运行。:
set_unexpected(convertUnexpected);
当你这么做了以后,一个unexpected异常将触发调用convertUnexpected函数。Unexpected异常被一种UnexpectedException新异常类型替换。如果被违反的异常规格包含UnexpectedException异常,那么异常传递将继续下去,好像异常规格总是得到满足。(如果异常规格没有包含UnexpectedException,terminate将被调用,就好像你没有替换unexpected一样)

另一种把unexpected异常转变成已知类型的方法是替换unexpected函数,让其重新抛出当前异常,这个新抛出的异常将被替换为标准类型的bad_exception。

void convertUnexpected()          //如果一个unexpected异常被
{                                 //抛出,这个函数被调用
  throw;                          //它只是重新抛出当前
}                                 //异常
 
set_unexpected(convertUnexpected);
// 安装 convertUnexpected做为unexpected的替代品


如果这么做,你应该在所有的异常规格里包含bad_exception(或它的基类,标准类exception)。你将不必再担心如果遇到unexpected异常会导致程序运行终止。任何不听话的异常都将被替换为bad_exception,这个异常代替原来的异常继续传递。
异常规格还有一个缺点就是即使一个high-level调用者准备处理被抛出的异常,它们仍然能导致unexpected被触发,比如:

class Session {                  //for modeling online
public:                          //sessions
  ~Session();
  ...
private:
  static void logDestruction(Session *objAddr) throw();
};
 
Session::~Session()
{
  try {
    logDestruction(this);
  }
  catch (...) {  }
}


异常规格throw()使得只要logDestruction抛出异常就和异常规格矛盾,程序被终止。(除非如前替换unexpected函数)





Item15. 理解异常处理所付出的代价:
三大开销分别为①为了在运行时处理异常,程序要记录大量的信息。即使你没有使用try,throw或catch关键字,你同样得付出一些代价。
②来自于try块,这还是假设程序没有抛出异常,这里讨论的只是在程序里使用try块的开销。③编译器为异常规格生成的代码与它们为try块生成的代码一样多,所以一个异常规格一般花掉与tyr块一样多的系统开销。

现在我们来到了问题的核心部分,看看抛出异常的开销。事实上我们不用太关心这个问题,因为异常是很少见的。80-20规则告诉我们这样的事件不会对整个程序的性能造成太大的影响。与一个正常的函数返回相比,通过抛出异常从函数里返回可能会慢三个数量级。这个开销很大。但是仅仅当你抛出异常时才会有这个开销,一般不会发生。但是如果你用异常表示一个比较普遍的状况,例如完成对数据结构的遍历或结束一个循环,那你必须重新予以考虑。



所以:只要可能尽量就采用不支持异常的方法编译程序,把使用try块和异常规格限制在你确实需要它们的地方,并且只有在确为异常的情况下(exceptional)才抛出异常。如果你在性能上仍旧有问题,总体评估一下你的软件以决定异常支持是否是一个起作用的因素。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: