C++ 异常使用须知

本文翻译自 https://dzone.com/articles/some-useful-facts-to-know-when-using-c-exceptions

相比错误代码,异常为错误处理提供了很多便利。这些好处包括:

  • 函数调用方不能简单将异常忽略掉,但调用方可以忽略对错误代码的检查。
  • 异常的传递可以跨越函数边界,错误代码不能。
  • 使用异常时可以将错误处理代码从代码控制逻辑主线上移开,这样可以大大提高代码的易读性。
  • 异常是构造函数,重载操作符里最佳的错误报告方式(译注:因为这二者都无法自己定义返回值)。

尽管有这些好处,然而大部分人仍然介怀异常的额外开销而不愿意使用。基于异常的实现机制,额外开销来自两方面:时间开销(增加运行时间)和空间开销(增加可执行文件和内存消耗)。在这二者之中,时间开销更被关注。然而,对于一个良好的C++异常实现,除非真的有异常被抛出,在正常执行时并不会引入运行时间开销[2]。C++异常带来的真正问题并不是执行性能,而是如何正确的使用异常。下面是一些对正确使用C++异常非常有用的事实。

1. 不要在析造函数里面抛出异常

考虑下面代码的情况

try
{
  MyClass1 Obj1;
  Obj1.DosomeWork();
  ...
}
catch(std::exception & ex)
{
   //do error handling
}

如果MyClass1:: DosomeWork()方法抛出了异常,在代码执行离开try 代码块之前,因为obj1是一个正常构造了的对象,obj1的析构函数需要被调用。那么请试想一下,如果在MyClass1的析构函数里面也抛出了异常会发生什么?这个异常抛出时,有一个异常正处于active状态。如果异常在抛出时,有另外一个异常处于active状态,C++的行为是调用terminate()方法,这个方法的作用是终止当前应用程序。因此要想避免两个异常同时处于active状态,析构函数一定不能抛出异常。

2. 所有被抛出的对象都会被拷贝

当一个异常被抛出时,鉴于原始的异常对象在堆栈回滚(stack unwinding)时会被析构掉,这个异常总是需要被重新拷贝一份。因此这个对象的拷贝构造函数一定会被调用。如果我们编程时没有写拷贝构造函数,那么C++会为我们提供一个缺省的拷贝构造函数。但是会出现一些情况,缺省的拷贝构造函数无法正常工作;尤其时当类成员时指针的时候。使用这样的对象作为异常对象时,一定要确保我们提供了正确的拷贝构造函数。现在,重点来了,C++里面对象拷贝所使用的拷贝构造函数是基于静态类型,而不是动态类型。 考虑下面的代码:

class Widget { ... };
class SpecialWidget: public Widget { ... };
void passAndThrowWidget()
{
    SpecialWidget localSpecialWidget;
    ...
    Widget& rw = localSpecialWidget;    // rw refers to a SpecialWidget
    throw rw;         // this throws an exception of type Widget!
}

在上面的代码中,throw语句抛出了一个Widget类型的对象,rw的静态类型是Widget。这可能并不是我们想要的执行行为。

3. 异常应该以引用方式捕获(Caught by reference)

catch语句可以有三种方式来捕获异常:

  1. 以值(by value)捕获
  2. 以指针捕获
  3. 以引用捕获

以值捕获成本高昂,且会遭遇到分片(Slicing)问题。成本高昂是因为这种方式每次都需要创建两个异常对象。因为堆栈回滚时,这个异常的原始对象可能因为超出作用域而被析构,因此当一个异常抛出时,无论这个异常是否被捕获,都需要创建这个异常的一个拷贝。如果这个异常是按值(以值)捕获,就需要创建另外一个拷贝以便传给catch语句。因此,如果异常时按值捕获,会有两个异常对象被创建,导致异常处理过程变慢。

分片问题来自于这样的场景,当一个子类对象被throw抛出,但catch语句的声明却是父类类型。在这种情况下,catch语句只会收到父类的拷贝,显然这样丢失了原始异常对象的属性。因此实际使用中,一定要避免按值捕获异常。

如果是按指针来捕获异常,代码会像下面这样:

void doSomething()
{
  try
  {
	     someFunction();               // might throw an exception*
  }
  catch (exception* ex)
  {                // catches the exception*;    
    ...            // no object is copied
  }
}

为了以指针方式捕获异常,抛出时就应该以指针抛出,而且抛出异常的地方必须保证异常对象在堆栈回滚后仍然可用。尽管仍然会创建异常对象的副本,但这时创建的副本是指针。因而必须有其他手段来保证异常对象在抛出后的可用。这点是可以做到的,可以把指针指向全局或者静态对象,或者把异常对象创建在堆里。

然而,异常的捕获者对于异常对象是如何创建的缺毫无主意,因此他也无法确定是否应该delete掉收到的异常对象指针。所以按指针捕获异常是欠妥的做法。此外,所有从标准函数抛出的异常都是对象,而不是指针。

按引用捕获异常不会有上面’按指针’或者‘按值’捕获带来的任何问题。使用者不需要担心如何delete捕获的异常对象。而且因为传递的是原始异常对象的引用,也不会有额外的异常对象被复制。
除此之外,按引用传递不会出现分片问题(slicing problem)。因此正确且高效的异常捕获方法是按引用。

4. 在异常情况下避免资源泄露

考虑下面的代码

void SomeFunction()
{
    SimpleObject* pObj = new SimpleObject();
    pObj->DoSomeWork();//could throw exceptions
    delete pObj;
}

在这个方法中,new操作创建了一个SimpleObject对象,然后SimpleObject::DoSomeWork()做了其他的工作,最终销毁这个对象。但是如果Object::DoSomeWork()抛出了异常,会发生什么呢?在这个场景,我们没有机会去delete pObj。这会导致内存泄漏。这只是一个简单的示例来展示异常可能导致的资源泄露,当然这个例子可以通过使用try catch语句来消除资源泄露。但是在实际条件下这种情况还是会在代码的各个点发生而且很难被一眼发现。这种情况的一个补救措施是使用标准库的自动指针(std::auto_ptr)[1]。

5. 抛出异常时不要把对象留在不一致状态

考虑下面的示例代码[4]:

template <class T>
class Stack
{
   unsigned nelems;
   int top;
   T* v;
 public:
   void push(T);
   T pop();
   Stack();
   ~Stack();
};
template <class T>
void Stack<T>::push(T element)
{
  top++;
  if( top == nelems-1 )
  {
    T* new_buffer = new (nothrow) T[nelems+=10];
    if( new_buffer == 0 )
      throw "out of memory";
    for(int i = 0; i < top; i++)
      new_buffer[i] = v[i];
    delete [ ] v;
    v = new_buffer;
  }
  v[top] = element;
}

如果"out of memory"异常被抛出,Stack::push() 方法会把Stack对象留在一个不一致状态,因为Stack的top已经被增加了,但是却没有push进去任何元素。当然,这个代码可以修改避免着各种情况发生。在抛出异常依然要特别留意,保证处于正确状态的对象在抛出异常后仍然是正确状态。
进一步地,这种情况经常伴随互斥量和锁发生。在下面这个幼稚的ThreadSafeQueue::Pushback()方法实现中,如果DoPushBack()方法抛出异常,_mutex将会保持被锁的状态,让ThreadSafeQueue对象处于不一致状态。要克服这种场景,可以使用lock_guards,就像用自动指针避免内存泄漏一样原理。需要注意的是lock_guard只在C++11后的标准库里才有。然而你可以很容易实现一个lock_guard 类。

template <class T>
void ThreadSafeQueue::Pushback(T element)
{
   _mutex.Lock();
   DoPushBack(T);
   _mutex.Unlock();
}

6. 使用异常规约(Exception Specification)时要小心[1]

如果一个方法抛出了一个其异常规约没有列出的异常,这个错误会在运行时被检测到,一个特殊函数unexcepted()会被调用。这个函数的缺省行为是调用terminate(),而terminate()的缺省行为是调用abort()。所以一个程序违反异常规约的缺省行为后果就是终止执行。考虑下面的代码:

void f1();                  // might throw anything
void f2() throw(int);        //throws only int
void f2() throw(int)
{
  ...
  f1();                  // legal even though f1 might throw
                         // something besides an int
  ...
}

当需要把不支持异常规约的旧代码和新代码一起集成时,这种场景下上面的代码是合法的。但是如果f1()抛出一些除int类型外的异常时程序就会终止执行,因为f2()不允许抛出int外的类型。在这种情况想要恢复着实会有点难度,但仍然是有方法可以做到。参考1:第14条详细介绍了这个问题的补救措施。

7. 异常再次抛出时应该用rethrow

有两种方法可以把一个捕获的异常传递给调用方,考虑下面的两个代码块:


catch (Widget& w)                 // catch Widget exceptions
{
    ...                             // handle the exception    
    throw;                          // rethrow the exception so it continues to propagate
}
catch (Widget& w)                 // catch Widget exceptions
{
    ...                             // handle the exception
     throw w;                        // propagate a copy of the
}

这两个代码块的唯一区别就是第一个抛出当前异常,而第二个代码块抛出了当前异常的一个拷贝。第二种情况有2个问题。一个是拷贝操作带来的性能成本,另外就是分片(slicing)问题。如果异常对象是Widget的子类,那么异常对象只有Widget部分被rethrow,这是因为拷贝操作是基于编译时静态类型进行的。

8. 捕获基类的catch语句应该放在捕获子类型的catch语句之后

当异常被抛出时,catch语句按照他们出现在代码中的次序被匹配。在catch语句里,一个异常对象的类型也会匹配到他的父类型,因为子类型是父类型的子集。所以,当一个子类对象被抛出时,如果父类catch语句先出现在代码里,这个语句就会被执行。而不再管后面还有针对子类型的catch语句。

参考

[1] More Effective C++, by Scott Meyers, 1996.

[2] When and How to Use Exceptions, by Herb Sutter, 2004 (http://www.drdobbs.com/when-and-how-to-use-exceptions/184401836).

[3] Technical Report on C++ Performance, 2005 (http://www.stroustrup.com/performanceTR.pdf).

[4] Exception Handling: A False Sense of Security, by Tom Cargill (http://ptgmedia.pearsoncmg.com/images/020163371x/supplements/Exception_Handling_Article.html).

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值