Beyond the Void
BYVoid
如何处理C++构造函数中的错误——兼谈不同语言的错误处理
本文简化字版由OpenCC转换

用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),所以几乎不存在兼容性问题,大可放心使用。

其他语言中的错误处理

Java倾向于大量使用异常,而且还把异常分为了两类分别是检查型异常(Checked Exception)和非检查型异常(Unchecked Exception),检查型异常就是java.lang.Exception的子类,用于报告需要检查的错误,也就是正常的业务逻辑,错误主要是由用户产生的,方便恢复或给出提示,譬如打开不存在的文件。而非检查型异常则是真正的系统异常,通常由软件缺陷导致,如数组下标越界、错误的类型转换等,这类异常继承于java.lang.RuntimeExceptionjava.lang.Error

Python和Java一样也倾向于使用异常,并不一定真的发生故障才抛出异常,譬如字符串转换为整数,如果字符串不合法,Python会抛出一个ValueError异常。甚至Python的迭代器在调用next()时没有更多的结果时会抛出StopIteration 异常。这是典型的用异常来处理正常控制流的方法,在Python中被广泛使用。按照优秀C++代码的标准来看,这是典型的对异常的滥用,既复杂又有额外开销,不推荐使用,但在Python中这是一个广泛遵循的约定。

相较于Java和Python,Go的错误处理是另一个极端,Go语言则根本没有异常的概念,而是普遍采用返回值的方式来表示错误,同时还提供了panicrecover语法。由于Go有多返回值的特性,避免了错误代码占用返回结果的弊端,所以你可以经常看到函数的最后一个返回值是error类型。由于总是用返回值传递错误,你可以看到Go代码中耦合了大量的错误处理,几乎再每条函数调用语句之后都有一个判断错误是否发生的语句。panicrecover机制十分类似于异常,程序在遇到panic时会一层一层退出调用栈,直到遇到recover。不过recover只在defer中定义,相当于一个函数只有一个recover,而且被recover恢复后会回到错误发生处继续向下执行代码。Go语言倾向于把一般错误都作为返回值传递,除非是非常可怕的、除了重置状态几乎无法恢复错误才会被panic语句抛出。

Go语言的recover机制和异常比起来,反倒更像Visual Basic语言中的On Error GoTo labelResume语法。这是一种非结构化的错误处理方式,具体是当声明有On Error GoTo label的函数发生错误以后,会调转到对应的行号,如果再遇到了Resume语句就会返回发生错误的语句后面的一条继续执行,例如下面这段代码:

Sub ErrorDemo
    On Error GoTo ErrorHandler
    Dim a as Integer
    a = 1/0 ' An error occurs.
    Print a ' Go back here
    Exit Sub

ErrorHandler:
    ' Code that handles errors.
    Resume
End Sub

Visual Basic中还有On Error Resume Next这样的万能错误处理语句,即遇到错误以后直接忽略并继续执行,这是一种非常危险而且不负责任的做法,但却可以在早期的Visual Basic代码中到处看到。事实上用返回值传递错误代码的时候许多人也并不处理而是直接忽略,这跟On Error Resume Next本质上没有什么区别,却比On Error Resume Next危害更大——因为On Error Resume Next至少还有个标记说明「老子就是这么不负责任」,但忽略错误返回值就难以被一眼发现了。

参考阅读


上次修改时间 2017-03-16

相关日志