现在的位置: 首页 > 综合 > 正文

如何处理C++构造函数中的错误

2013年04月05日 ⁄ 综合 ⁄ 共 3275字 ⁄ 字号 评论关闭

用C++写代码的时候总是避免不了处理错误,一般来说有两种方式,通过函数的返回值或者抛出异常。C语言的错误处理一律是通过函数的返回值来判断的,一般是返回0NULL或者-1表示错误,或者直接返回错误代码,具体是哪种方式没有统一的规定,各种API也各有各的偏好。譬如fopen函数,当成功时返回文件指针,失败时返回NULL,而POSIX标准的open函数则在成功时返回0或者正数,失败时返回-1,然后需要再通过全局变量errno来判断具体错误是什么,配套的还有一系列perrorstrerror这样的函数。

C++的错误处理方式

C++号称向下兼容C语言,于是就将C语言通过返回值的错误处理方式也搬了进来。但C++最大的不同是引入了异常机制,可以用throw产生一个异常,并通过trycatch来捕获。于是就混乱了,到底是什么时候使用返回值表示错误,什么时候使用异常呢?首先简单谈论一下异常和返回值的特点。

异常的优点

  1. 错误信息丰富,便于获得错误现场
  2. 代码相对简短,不需要判断每个函数的返回值

异常的缺点

  1. 使控制流变得复杂,难以追踪
  2. 开销相对较大

返回值的优点

  1. 性能开销相对小
  2. 避免定义异常类

返回值的缺点

  1. 程序员经常「忘记」处理错误返回值
  2. 每个可能产生错误的函数在调用后都需要判断是否有错误
  3. 与「真正的」返回值混用,需要规定一个错误代码(通常是0-1NULL

使用异常还是返回值

我的观点是,用异常来表示真正的、而且不太可能发生的错误。所谓不太可能发生的错误,指的是真正难以预料,但发生了却又不得不单独处理的,譬如内存耗尽、读文件发生故障。而在一个字符串中查找一个子串,如果没有找到显然应该是用一个特殊的返回值(如-1),而不应该抛出一个异常。

一句话来概况就是不要用异常代替正常的控制流,只有当程序真的「不正常」的时候,才使用异常。反过来说,当程序真正发生错误了,一定要使用异常而不是返回一个错误代码,因为错误代码总是倾向于被忽略。如果要保证一个以返回值来表示错误代码的函数的错误正确地向上传递,需要在每个调用了可能产生错误的函数后面都判断一下是否发生了错误,一旦发生了不可解决的错误,就要终止当前函数(并释放当前函数申请的资源),然后向上传递错误。这样一来错误处理代码会被重复地写好几遍,十分冗杂,譬如下面代码:

int func(int n) {
  int fd = open("path/to/file", O_RDONLY);
  if (fd == -1) {
     return ERROR_OPEN;
  }
  int* array = new[n];
  int err;
  err = do_something(fd, array);
  if (err != SUCCESS) {
     delete[] array;
     return err;
  }
  err = do_other_thing();
  if (err != SUCCESS) {
     delete[] array;
     return err;
  }
  err = do_more_thing();
  if (err != SUCCESS) {
     delete[] array;
     return err;
  }
  delete[] array;
  return SUCCESS;
}

对使用异常容易增加函数出口的指控其实是不成立的,因为即使使用返回值,这些出口也是免不了的,除非程序员有意或无意忽略掉,但异常是不可忽略的。如果你认为可以把判断错误的if语句缩写到一行使代码变得「更清晰」,那么我只能说是自欺欺人。

有些错误几乎总是可以被立即恢复(譬如前面所说的查找一个字符串不存在的子串,甚至都不能说这是一个「错误」),而且返回值本身就传递一定信息,就不需要使用异常了。

鉴于C++没有统一的ABI,并不建议在模块的接口上使用异常。如果要使用,就要把可能曝露给用户的异常全部声明出来,不要把其他类型的异常丢给用户去处理,尤其是内部状态——模块的使用者通常也不会关心模块内部具体是哪条语句发生错误了。

构造函数中的错误

有一个相当实际的问题是,如何处理构造函数的错误?我们都知道构造函数是没有返回值的,怎么办呢?通常有三种常见的处理方法,标记错误状态使用一个额外的initialize函数来初始化,或者直接抛出异常

合格的C++程序员都知道C++的析构函数中不应该抛出异常,一旦析构函数中的异常没有被捕获,整个程序都要被中止掉。于是许多人就对在构造函数中抛出异常也产生了对等的恐惧,宁可使用一个额外的初始化函数在里面初始化对象的状态并抛出异常(或者返回错误代码)。这样做违背了对象产生和初始化要在一起的原则,强迫用户记住调用一个额外的初始化函数,一旦没有调用直接使用了其他函数,其行为很可能是未定义的。

使用初始化函数的惟一好处可能是避免了手动释放资源(释放资源的操作交给析构函数来做),因为C++的一个特点是构造函数抛出异常以后析构函数是不会被调用的,所以如果你在构造函数里面申请了内存或者打开了资源,需要在异常产生时关闭。但想想看其实并不能完全避免,因为有些资源可能是要在可能产生错误的函数调用过后才被申请的,还是无法完全避免手工的释放。

标记错误状态也是一种常见的形式,譬如STL中的ifstream类,当构造时传入一个无法访问的文件作为参数,它不会返回任何错误,而是标记的内部状态为不可用,用户需要手工通过is_open()函数来判断是否打开成功了。同时它还有good()fail()两个函数,同时也重载了bool类型转换运算符用于在if语句中判断。标记状态的方法在实践中相当丑陋,因为在使用前总是需要判断它是否「真的创建成功了」。

最直接的方法还是在构造函数中抛出异常,它并不会向析构函数中抛出异常那样有严重的后果,只是需要注意的是抛出异常以后对象没有被创建成功,析构函数也不会被调用,所以应该自行把申请的资源全部都释放掉。

如何在构造函数中捕获异常

构造函数与普通函数有一个很不一样特性,就是构造函数可以有初始化列表,例如下面的代码:

class B {
 public:
  B(int val) : val_(val * val) {
  }
 private:
  int val_;
};

class A {
 public:
  A(int val) : b_(val) {
    a_ = val;
  }
 private:
  int a_;
  B b_;
};

以上的代码中A的构造函数的函数体的语句在执行之前会先调用B的构造函数,这时候问题在于,如果B的构造函数抛出了异常,A该如何捕获呢?一个迂回的做法是在A中把B的实例声明为指针,在构造函数和析构函数中分别创建和删除,这样就能捕获到异常了。不过,实际上是有更简单的做法的。下面我要介绍一个C++的很不常见的语法:函数作用域级别的异常捕获。

class B {
 public:
  B(int val) : val_(val * val) {
    throw runtime_error("wtf from B");
  }
 private:
  int val_;
};

class A {
 public:
  A(int val) try : b_(val) {
    a_ = val;
  } catch (runtime_error& e) {
    cerr << e.what() << endl;
    throw runtime_error("wtf from A");
  }
 private:
  int a_;
  B b_;
};

注意上面A的构造函数,在参数列表后和初始化列表前增加了try关键字,然后构造函数就被分割为了两部分,前面是初始化,后面是初始化时的错误处理。需要指出的是,catch块里面捕获到的异常不能被忽略,即catch块中必须有一个throw语句重新抛出异常,如果没有,则默认会将原来捕获到的异常重新抛出,这和一般的行为是不同的。例如下面代码运行可以发现A会将捕获到的异常原封不动抛出:

class A {
 public:
  A(int val) try : b_(val) {
    a_ = val;
  } catch (runtime_error& e) {
    cerr << e.what() << endl;
  }
 private:
  int a_;
  B b_;
};

这种语法是C++的标准,而且目前已经被所有的主流C++编译器支持(VS2010、g++ 4.2、clang 3.1),所以几乎不存在兼容性问题,大可放心使用。

抱歉!评论已关闭.