许多入门程序员往往不能正确地认识错误码与异常的异同,导致他们经常会有一些错误的认知,这些认知阻碍了他们进一步了解异常。而异常是C++标准委员会推荐的错误处理机制,不了解异常,很大程度上意味着你并不了解现代C++。
本文旨在向大家阐明,错误码与异常本质上是一样的东西,都是为了处理错误,只不过是具体写法上有所不同。
错误处理与恢复是什么意思
首先,我们先广义地来看问题。我们的问题是什么?我们的问题是,当我们的程序执行到某个函数时,这个函数无法完成它所承诺的功能,即,此函数遇到了一个错误。这个错误导致我们无法完成我们想做的事情。
此时,为了保证我们的程序可以继续正常运行,我们需要从这个错误中恢复。所谓恢复意味着:
- 将程序恢复到一个可预期的状态
- 保证没有资源泄漏
同时,我们需要对这个错误情况进行处理:
- 吞掉错误:如log或者ignore
- 终止程序
- 尝试重试或者使用其他fallback方法
- 向上报告:如果以上方法都不可取,说明,当前函数并无法对此错误进行处理,我们需要将此错误向上报告,直到有人可以处理此错误
需要注意地是,如果函数f出现了错误,无法完成其所承诺的功能,f的调用者,以及调用者的调用者,一直到某一层调用栈,都会无法完成其所承诺的功能,也即,调用栈的每一层,都需要对此错误进行恢复。但是,如果某一层调用栈,无资源泄漏,也没有破坏程序不变式,错误可以透明地向上报告,不需要额外的恢复动作。
另一方面,当错误传递到某一层函数时,如果此函数的功能并未由于此错误受到影响,或者此错误是此函数预期的,那么,错误传递可以到此为止,我们可以在这一层,将错误消化掉,也即,此错误已经被处理完成。例子如:http服务器的请求循环中,如果某个请求处理失败,请求循环直接返回用户返回一个错误消息即可,如http 404。
错误对象
当函数遇到一个错误时,我们需要描述此错误,描述此错误的对象,即为错误对象,常见的例子如:
- 错误码
- 异常对象
我们会通过这个错误对象来报告我们的错误,并尝试将错误对象向调用者不断传递,直到某个调用者能够处理这个错误。
从错误对象角度来说,错误码与异常对象没有本质的区别,大家可以简单认为,一个错误码,对应一个异常类。错误码用某个值,表示某类错误;异常用某个类,表示某类错误:
const
异常对象相较于错误码的优点在于:
- 异常对象可以携带更多的信息,比如,file_not_found_error异常对象,可以携带具体的找不到的文件名,而错误码ENOTFOUND则不能。
- 异常对象是层次化的,比如network_error异常类,可以有多个子类,如,connection_failed、write_aborted、connection_timed_out等子类,这样,当处理时,如果不想区分具体的类别,直接catch network_error,然后统一处理。如果想特别处理connection_failed,可以额外catch此异常类。而错误码则需要对所有错误情况一一判断。
异常对象的缺点也是有的,但是对能够认识到异常对象的缺点的大佬,应该已经不需要看我的文章了。
错误的恢复
当函数执行遇到错误,使之不能完成承诺的功能时,大多数情况下,我们需要终止此函数的执行,但是终止之前,我们需要保证:
- 程序恢复到一个正常的状态(因为函数执行过程中,或者已经修改了程序的状态,导致程序处于一个中间状态)
- 无资源泄漏
因此,我们通过在遇到错误后,执行一些代码,来让程序从错误中恢复。
对于不变式的恢复:
为了恢复程序的invariant,通常,出错时我们需要手动处理。
int
对于资源泄漏问题:
其他语言中,经常使用错误处理来管理资源,从而保证错误出现时无资源泄漏。C++中,会使用专门的机制RAII来管理资源,保证资源不会泄漏。因此,在C++中,只要正确使用了RAII,无论使用错误码,还是使用异常处理,都不需要考虑资源泄漏的问题。
int foo(const char* filename)
{
File_handle fh(filename, "w");
// ...
int r = doX();
if (r != 0) {
// 直接将错误向上传递即可,打开的文件由File_handle管理,会自动释放的,不用担心资源泄漏
return r;
}
// ...
}
void foo(const char* filename)
{
File_handle fh(filename, "w");
// ...
// 如果出错了,直接让异常对象自动向上抛就行子,打开的文件会自动释放,不用担心资源泄漏
doX();
// ...
}
错误的报告
函数从错误中恢复后,需要决定,是在本地将错误处理掉,还是上报。显然,当函数f()无法完成承诺的任务时,f的调用者g,也是无法完成自己所承诺的任务的,此时,我们需要将错误报告给g。如此重复,调用栈的每一层,都需要进行恢复,直到某一层能够处理此错误(也即,这一层函数遇到错误,其所承诺的功能还是可以正常完成;或者这一层已经是调用栈的顶层了)。
对于错误码,我们需要手动将错误对象向上传递,如果忘记上报,错误就被静默的丢掉了。它可能导致某一层意识不到已经出错,从而无法执行恢复动作,导致程序在一个错误的状态下越走越远。
int
对于异常,它可以自动上报:
void
错误的处理
随着错误对象的不断向上报告,调用栈的每一层都需要进行错误恢复。直到我们到达了某一层:
- 错误可以被忽略(即,此错误的发生,实际上没有影响当前函数完成自己的功能):比如你有一个请求循环,在处理某个具体的请求时出了错,此时,直接catch此错误,然后打条日志。
- 我们可以尝试其他的方法,来将刚刚失败的工作完成:比如,从网络加载配置失败,收到network_error,则改从本地读取配置文件。
- 当前函数已经是顶层了,无法继续上报:如main函数、线程的main函数、请求处理循环等。
- 错误对象需要转换成另外一种错误对象,再继续传播:比如,一个http服务器的项层,某个请求处理错误后,会将异常转换成http错误码,然后返回给用户。
以上四种情况实际上不完全正交。
对错误处理的角度来看,错误码与异常实际上是没有任何区别的。无论你使用错误码,还是使用异常,你都要决定,具体应该怎么处理当前错误对象。