2023年11月29日发(作者:)

如何处理C++构造函数中的错误——兼谈不同语⾔的错误处理_

C++写代码的时候总是避免不了处理错误,⼀般来说有两种⽅式,通过函数的返回值或者抛出异常。C语⾔的错误处理⼀律是通过函数的

返回值来判断的,⼀般是返回或者表⽰错误,或者直接返回错误代码,具体是哪种⽅式没有统⼀的规定,各种API也各有各的偏

0NULL-1

好。譬如函数,当成功时返回⽂件指针,失败时返回,⽽POSIX标准的函数则在成功时返回或者正数,失败时返回,然后需

fopenNULLopen0-1

要再通过全局变量来判断具体错误是什么,配套的还有⼀系列这样的函数。

errnoperrorstrerror

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_;

};

以上的代码中的构造函数的函数体的语句在执⾏之前会先调⽤的构造函数,这时候问题在于,如果的构造函数抛出了异常,该如何捕获

ABBA

呢?⼀个迂回的做法是在中把的实例声明为指针,在构造函数和析构函数中分别创建和删除,这样就能捕获到异常了。不过,实际上是有

AB

更简单的做法的。下⾯我要介绍⼀个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) {

};

注意上⾯的构造函数,在参数列表后和初始化列表前增加了关键字,然后构造函数就被分割为了两部分,前⾯是初始化,后⾯是初始化

Atry

时的错误处理。需要指出的是,块⾥⾯捕获到的异常不能被忽略,即块中必须有⼀个语句重新抛出异常,如果没有,则默认会

catchcatchthrow

将原来捕获到的异常重新抛出,这和⼀般的⾏为是不同的。例如下⾯代码运⾏可以发现会将捕获到的异常原封不动抛出:

A

class A {

public:

A(int val) try : b_(val) {

a_ = val;

} catch (runtime_error& e) {

cerr << () << endl;

}

private:

int a_;

B b_;

};

这种语法是C++的标准,⽽且⽬前已经被所有的主流C++编译器⽀持(VS2010g++ 4.2clang 3.1),所以⼏乎不存在兼容性问题,⼤可

放⼼使⽤。

其他语⾔中的错误处理

Java倾向于⼤量使⽤异常,⽽且还把异常分为了两类分别是检查型异常(Checked Exception)和⾮检查型异常(Unchecked Exception),检查

型异常就是的⼦类,⽤于报告需要检查的错误,也就是正常的业务逻辑,错误主要是由⽤户产⽣的,⽅便恢复或给出提⽰,

ion

譬如打开不存在的⽂件。⽽⾮检查型异常则是真正的系统异常,通常由软件缺陷导致,如数组下标越界、错误的类型转换等,这类异常继承

eException

PythonJava⼀样也倾向于使⽤异常,并不⼀定真的发⽣故障才抛出异常,譬如字符串转换为整数,如果字符串不合法,Python会抛出⼀

异常。甚⾄Python的迭代器在调⽤时没有更多的结果时会抛出异常。这是典型的⽤异常来处理正常控制流的⽅

ValueErrornext()StopIteration

法,在Python中被⼴泛使⽤。按照优秀C++代码的标准来看,这是典型的对异常的滥⽤,既复杂⼜有额外开销,不推荐使⽤,但在Python

这是⼀个⼴泛遵循的约定。

相较于JavaPythonGo的错误处理是另⼀个极端,Go语⾔则根本没有异常的概念,⽽是普遍采⽤返回值的⽅式来表⽰错误,同时还提供

语法。由于Go有多返回值的特性,避免了错误代码占⽤返回结果的弊端,所以你可以经常看到函数的最后⼀个返回值

panicrecover

类型。由于总是⽤返回值传递错误,你可以看到Go代码中耦合了⼤量的错误处理,⼏乎再每条函数调⽤语句之后都有⼀个判断错误是

error

否发⽣的语句。机制⼗分类似于异常,程序在遇到时会⼀层⼀层退出调⽤栈,直到遇到。不过只在中定

panicrecoverpanicrecoverrecoverdefer

义,相当于⼀个函数只有⼀个,⽽且被恢复后会回到错误发⽣处继续向下执⾏代码。Go语⾔倾向于把⼀般错误都作为返回值传

recoverrecover

递,除⾮是⾮常可怕的、除了重置状态⼏乎⽆法恢复错误才会被语句抛出。

panic

Go语⾔的机制和异常⽐起来,反倒更像Visual Basic语⾔中的语法。这是⼀种⾮结构化的错误处理⽅式,具

recoverOn Error GoTo labelResume

体是当声明有的函数发⽣错误以后,会调转到对应的⾏号,如果再遇到了语句就会返回发⽣错误的语句后⾯的⼀条继

On Error GoTo labelResume

续执⾏,例如下⾯这段代码:

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 NextOn Error Resume NextOn Error Resume Next

本质上没有什么区别,却⽐危害更⼤——因为⾄少还有个标记说明「⽼⼦就是这么不负责

任」,但忽略错误返回值就难以被⼀眼发现了。