跳转至

异常和错误处理

为什么使用异常?

使用异常的目的是使得代码更简介、并减少没有检查函数的返回错误码的情况。

函数内发生错误时,告知主调方出错常用的方式是返回一个错误码。在类中还可以提供一个成员函数,通过记录错误的状态并在主调方主动调用时返回错误。

在错综复杂的逻辑流中这种情况更容易发生主调方没有显式地检查错误码的情况,且检查错误码容易降低代码的整洁程度和可读性。

如,使用异常的调用链:

void f1()
{
  try {
    // ...
    f2();
    // ...
  } catch (some_exception& e) {
    // ...code that handles the error...
  }
}
void f2() { ...; f3(); ...; }
void f3() { ...; f4(); ...; }
void f4() { ...; f5(); ...; }
void f5() { ...; f6(); ...; }
void f6() { ...; f7(); ...; }
void f7() { ...; f8(); ...; }
void f8() { ...; f9(); ...; }
void f9() { ...; f10(); ...; }
void f10()
{
  // ...
  if ( /*...some error condition...*/ )
    throw some_exception();
  // ...
}

未使用异常的调用链:

int f1() {
  // ...
  int rc = f2();
  if (rc == 0) {
    // ...
  } else {
    // ...code that handles the error...
  }
}
int f2() {
  // ...
  int rc = f3();
  if (rc != 0)
    return rc;
  // ...
  return 0;
}
int f3() {
  // ...
  int rc = f4();
  if (rc != 0)
    return rc;
  // ...
  return 0;
}

// ...

int f9() {
  // ...
  int rc = f10();
  if (rc != 0)
    return rc;
  // ...
  return 0;
}
int f10() {
  // ...
  if (...some error condition...)
    return some_nonzero_error_code;
  // ...
  return 0;
}

异常的代价

一个主要的问题是性能,在异常开启时性能相比异常关闭时较低。而如今的编译器在开启异常后对比关闭异常的性能损耗大概只有3%,且错误码加条件判断的形式同样也是有性能损耗的。

另一个问题则是二进制文件的体积膨胀,这是栈展开机制导致的。

对于编码人员而言,如果函数的注释或文档不清晰,主调方可能不知道调用的函数会不会抛出异常。c++11虽然新增了关键字noexcept表示该函数不会抛出异常,但没有except关键字表示该函数可能会抛出异常。

最重要的是如何写出异常安全的代码,尤其是如何保证throw和catch中间路径的所有代码都是异常安全的。

异常安全

异常安全是指当异常发生时,不会泄漏资源,也不会使系统处于不一致的状态。 通常有三个异常安全级别:基本保证、强烈保证、不抛异常(nothrow)保证。

  • 基本保证。抛出异常后,对象仍然处于合法(valid)的状态。但不确定处于哪个状态。
  • 强烈保证。如果抛出了异常,程序的状态没有发生任何改变。就像没调用这个函数一样。
  • 不抛异常保证。这是最强的保证,函数总是能完成它所承诺的事情。

异常安全需要尽量让必须手动管理的资源在栈上资源后初始化,保证异常发生时可以回收所有资源和代码逻辑的一致性。

void fct(string s){
    File_handle f(s,"r");   // File_handle's constructor opens the file called "s"
    // use f
} // here File_handle's destructor closes the file  
void fct(string s){
    File_handle f(s,"r");   // File_handle's constructor opens the file called "s"
    // use f
    fclose(f);  // close the file
}

异常的本意是处理本地不能解决的错误,比如构造函数中的内存分配、无法打开的文件等,不要将异常作为另一种形式的错误码。

不要用异常做什么

  • 将throw仅用于作为告知主调方错误发生的手段
  • 将应该需要处理的错误或者遇到和期望不符的情况的时候抛出异常,应该用assert或其他中断机制将这些情况显式的抛出。

  • 只捕获能够处理的异常

不要滥用异常,要谨慎地使用异常。

构造函数中可以使用异常吗?析构函数呢?

构造函数没有返回类型,因此如果不能合理的初始化,那么需要抛出异常告知主调方。但需要保证异常安全。

析构函数应该从不抛出异常,如果析构函数发生了错误应该打日志或中止程序。这是因为异常的栈展开(stack unwinding)机制导致的。

抛出异常时将暂停当前函数的执行,开始查找匹配的catch子句。首先检查throw本身是否在try块内部,如果是,检查与该try相关的catch子句,看是否可以处理该异常。如果不能处理,就退出当前函数,并且释放当前函数的内存并销毁局部对象,继续到上层的调用函数中查找,直到找到一个可以处理该异常的catch。这个过程称为栈展开(stack unwinding)。当处理该异常的catch结束之后,紧接着该catch之后的点继续执行。

即所有在

throw Foo()

} catch (const Foo& e) {

之间的所有栈帧都会被释放掉。

而在栈展开的期间,如果另外一个析构函数再次抛出了异常,由于c++的运行时系统无法确定处理哪个异常,则会导致terminate()的调用。

看起来只要保证一个析构函数在栈展开的期间保证不会遇到另一个可能抛出异常的析构函数就可以避免这个问题了?从机制来说确实是可行的,但是长期以往代码的复杂程度将呈指数级增长,可读性和可维护程度严重下降,反而得不偿失。

参考

https://toutiao.io/posts/i65j0s3/preview

https://isocpp.org/wiki/faq/exceptions#ctors-can-throw

http://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines#S-errors