错误处理的3种主流方式

170 阅读5分钟

使用返回值(错误码)

使用返回值来表征错误,是最古老也是最实用的一种方式,它使用范围很广,从函数返回值、到操作系统的系统调用的错误码errno、进程退出的错误码retval,甚至HTTP API的状态码

在C语言中,如果fopen(filename)无法打开文件,会返回NULL,调用者通过判断返回值是否为NULL,来进行相应的错误处理

例子


size_t fread(void* ptr, size_t size, size_t nmemb, FILE* stream)

单看这个接口,我们很难直观了解,当读文件出错时,错误是如何返回的。从文档中得知,如果返回的size_t 和 传入的 size_t 不一致,那么要么发生了错误,要么是读到文件尾(EOF), 调用者要进一步通过ferror才能得到更详细的错误

所以Golang 对其做了扩展,在函数返回的时候,可以专门携带一个错误对象。比如上下文的fread,在Golang下可以这么定义


func Fread(file *File, b []byte) (n int, err error)

Golang 这样,区分开错误返回和正常返回,相对C来说进一步

但是使用返回值的方式,始终有个致命的问题: 在调用者调用时,错误就必须得到处理或者显式的传播

如果函数A调用了函数B,在A返回错误的时候,就要把B的错误转换A的错误,显示出来

image.png

这样写出来的代码会非常冗长,对我们开发者的用户体验不太好。如果不处理,又会丢掉这个错误信息,造成隐患

使用异常

因为返回值不利于错误的传播,有诸多限制,Java等很多语言使用异常来处理错误

可以把异常看成一种关注点分离(Separation of Concerns): 错误的产生和错误的处理完全被分隔开,调用者不必关系错误,而被调用者也不强求调用者关心错误

程序中任何可能出错的地方,都可以抛出异常。而异常可以通过栈回溯(stack unwind)被一层层自动传递,直到遇到捕获异常的地方,如果回溯到main函数还无人捕获,程序就会崩溃

image.png

image.png

使用异常来返回可以极大地简化错误处理的流程,它解决了返回值的传播问题

然而,上图中异常返回的过程看上去很直观,就像数据库中的事务(transaction) 在出错时会被整体撤销(rollback)一样。但实际上,这个过程比你想象的复杂,而且需要额外操作异常安全(exception safety)

看下面用来切换背景图片的(伪)代码


void transition(...) {

lock(&mutex);

delete background;

++changed;

background = new Background(...);

unlock(&mutex);

}

试想,如果在创建新的背景时失败,抛出异常,会跳过后续的处理流程,一路栈回溯到try catch 的代码,那么,这里锁住的mutex无法释放,而已有的背景被清空,新的背景没有创建,程序进入到一个奇怪的状态

确实在大多数情况下,用异常更容易写代码,但当异常安全无法保证时,程序的正确性会受到很大的挑战。因此,你在使用异常处理时,需要特别注意异常安全,尤其是在并发环境下

而比较讽刺的是,保证异常安全的第一个原则就是: 避免抛出异常。这也是Golang在语言设计时避开了常规的异常,走回返回值的老路的原因

异常处理另一个比较严重的问题是:开发者会滥用异常。只要有错误,不论是否严重、是否可恢复,都一股脑抛个异常。到了需要的地方,捕获一下了之,殊不知,异常处理的开销要比处理返回值大得多,滥用会有很多额外的开销

使用类型系统

在使用返回值处理错误的时候,已经看到了类型系统雏形

错误信息既然可以通过已有类型携带,或者通过多返回值的方式提供,那么通过类型来表征错误,使用一个内部包含正常返回类型和错误返回类型的复合类型

通过类型系统来强制错误的处理和传递,是不是可以到达更好的效果呢?

  • 的确如此。这种方式被大量使用在有强大类型系统支持的函数式编程语言中,如Haskell/Scala/Swift

  • 其中最典型的包含了错误类型的复合类型是 Haskell 的 Maybe 和 Either 类型

Maybe 类型允许数据包含一个值(Just) 或者 没有值(Nothing), 这对简单的不需要类型的错误很有用。还是以打开文件为例,如果我们只关心成功打开文件的句柄,那么Maybe就足够了

当我们需要更为复杂的错误处理时,我们可以使用Either类型。它允许数据是Left a 或者 Right b。其中,a是运行出错的数据类型,b可以是成功的数据类型

image.png

可以看到,这种方法依旧是通过返回值返回错误,但是错误被包裹在一个完整的、必须处理的类型中,比 Golang 的方法更安全

结论

使用返回值返回错误的一大缺点是,错误需要被调用者立即处理或者显式传递

但是使用 Maybe / Either 这样的类型来处理错误的好处是,我们可以用函数式编程的方法简化错误的处理,比如 map、fold 等函数,让代码相对不那么冗余

需要注意的是,很多不可恢复的错误,如“磁盘写满,无法写入”的错误,使用异常处理可以避免一层层传递错误,让代码简洁高效,所以大多数使用类型系统来处理错误的语言,会同时使用异常处理作为补充