首页 > C/C++语言 > C/C++基本语法 > 如何编写异常安全的C++代码(二)
2006
10-12

如何编写异常安全的C++代码(二)

 考察前面的几个例子,也许你已经发现了,所谓异常安全的代码,竟然就是如何避免try…catch的代码,这和直觉似乎是违背的。有些时候,事情就是如此违背直觉。异常是无处不在的,当你不需要关心异常或者无法处理异常的时候,就应该避免捕获异常。除非你打算捕获所有异常,否则,请务必把未处理的异常再次抛出。try…catch的方式固然能够写出异常安全的代码,但是那样的代码无论是清晰性和效率都是难以忍受的,而这正是很多人抨击C++异常的理由。在C++的世界,就应该按照C++的法则来行事。


  如果按照上述的原则行事,能够实现基本保证了吗?诚恳地说,基础设施有了,但技巧上还不够,让我们继续分析不够的部分。


  对于一个方法常规的执行过程,我们在方法内部可能需要多次修改对象状态,在方法执行的中途,对象是可能处于非法状态的(非法状态 != 未知状态),如果此时发生异常,对象将变得无效。利用前述的手段,在pair_guard的析构中修复对象是可行的,但缺乏效率,代码将变得复杂。最好的办法是……是避免这么作,这么说有点不厚道,但并非毫无道理。当对象处于非法状态时,意味着此时此刻对象不能安全重入、不能共享。现实一点的做法是:


  a.每一次修改对象,都确保对象处于合法状态


  b.或者当对象处于非法状态时,所有操作决不会失败。


  在接下来的强保证的讨论中细述如何做到这两点。


  强保证是事务性的,这个事务性和数据库的事务性有区别,也有共通性。实现强保证的原则做法是:在可能失败的过程中计算出对象的目标状态,但是不修改对象,在决不失败的过程中,把对象替换到目标状态。考察一个不安全的字符串赋值方法:


  string& operator=(const string& rsh){


  if (this != rsh){


  myalloc locked_pool(m_data);


  locked_pool.deallocate(m_data);


  if (rsh.empty())


  m_data = NULL;


  else{


  m_data = locked_pool.allocate(rsh.size() + 1);


  never_failed_copy(m_data, rsh.m_data, rsh.size() + 1);


  }


  }


  return *this;


  }


  locked_pool是为了锁定内存页。为了讨论的简单起见,我们假设只有locked_pool构造函数和allocate是可能抛出异常的,那么这段代码连基本保证也没有做到。若allocate失败,则m_data取值将是非法的。参考上面的b条目,我们可以这样修改代码:


  myalloc locked_pool(m_data);


  locked_pool.deallocate(m_data); //进入非法状态


  m_data = NULL; //立刻再次回到合法状态,且不会失败


  if(!rsh.empty()){


  m_data = locked_pool.allocate(rsh.size() + 1);


  never_failed_memcopy(m_data, rsh.m_data, rsh.size() + 1);


  }


  现在,如果locked_pool失败,对象不发生改变。如果allocate失败,对象是一个空字符串,这既不是初始状态,也不是我们预期的目标状态,但它是一个合法状态。我们阐明了实现基本保证所需要的技巧部分,结合前述的基础设施(RAII的运用),完全可以实现基本保证了…哦,其实还是有一点疏漏,不过,那就留到最后吧。


  继续,让上面的代码实现强保证:


  myalloc locked_pool(m_data);


  char* tmp = NULL;


  if(!rsh.empty()){


  tmp = locked_pool.allocate(rsh.size() + 1);


  never_failed_memcopy(tmp, rsh.m_data, rsh.size() + 1); //先生成目标状态


  }


  swap(tmp, m_data); //对象安全进入目标状态


  m_alloc.deallocate(tmp); //释放原有资源


  强保证的代码多使用了一个局部变量tmp,先计算出目标状态放在tmp中,然后在安全进入目标状态,这个过程我们并没有损失什么东西(代码清晰性,性能等等)。看上去,实现强保证并不比基本保证困难多少,一般而言,也确实如此。不过,别太自信,举一种典型的很难实现强保证的例子,对于区间操作的强保证:


  for(itr = range.begin(); itr != range.end(); ++itr){


  itr->do_something();


  }


  如果某个do_something失败了,range将处于什么状态?这段代码仍然做到了基本保证,但不是强保证的,根据实现强保证的基本原则,我们可以这么做:


  tmp = range;


  for (itr = tmp.begin(); itr != tmp.end(); ++itr){


  itr->do_something();


  }


  swap(tmp, range);


  似乎很简单啊!呵呵,这样的做法并非不可取,只是有时候行不通。因为我们额外付出了性能的代价,而且,这个代价可能很大。无论如何,我们阐述了实现强保证的方法,怎么取舍则由您决定了。


  接下来讨论最后一种异常安全保证:不会失败。


  通常,我们并不需要这么强的安全保证,但是我们至少必须保证三类过程不会失败:析构函数,释放类函数,swap。析构和释放函数不会失败,这是RAII技术有效的基石,swap不会失败,是为了“在决不失败的过程中,把对象替换到目标状态”。我们前面的所有讨论都是建立在这三类过程不会失败的基础上的,在这里,弥补了上面的那个疏漏。


  一般而言,语言内部类型的赋值、取地址等运算是不会发生异常的,上述三类过程逻辑上也是不会发生异常的。内部运算中,除法运算可能抛出异常。但是地址访问错通常是一种错误,而不是异常,我们本应该在前条件检查中就发现的这一点的。所有不会发生异常操作的简单累加,仍然不会导致异常。


  好了,现在我们可以总结一下编写异常安全代码的几条准则了:


  1.只在应该使用异常的地方抛出异常


  2.如果不知道如何处理异常,请不要捕获(截留)异常。


  3.充分使用RAII,旁路异常。


  4.努力实现强保证,至少实现基本保证。


  5.确保析构函数、释放类函数和swap不会失败。


  另外,还有一些语言细节问题,因为和这个主题有关也一并列出:


  1.不要这样抛出异常:throw new exception;这将导致内存泄漏。


  2.自定义类型,应该捕获异常的引用类型:catch(exception e)或catch(const exception e)。


  3.不要使用异常规范,即使是空异常规范。编译器并不保证只抛出异常规范允许的异常,更多内容请参考相关书籍。


留下一个回复