谈谈错误处理

作为程序员要虚心 ——鲁迅
这里写的是自己对于错误处理的一些理解,末尾列出了参考文章,如果有侵权,可以联系我修改。如果有写得不对的地方,请重拍!

1. 引言

错误处理是一个历史很悠久的话题了,其中也有很多相关的文献,很多大牛也针对这个话题频出奇招。作为一个软件开发者,也应该对其中不同的处理模式和背后的思想有些许了解。

2. 错误/异常

那什么是错误呢?对于软件代码而言,调用方违反被调用函数的precondition、或一个函数无法维持其理应维持的invariants、或一个函数无法满足它所调用的其它函数的precondition、或一个函数无法保证其退出时的postcondition;以上所有情况都属于错误。

wikipedia把工程学中的错误定义为:系统或对象中,预期中的结果和实际的结果之间的差异。Error

而对于异常,除了“错误”包含的属性外,wikipedia中还提到了“异常”的“终止语义Termination semantics

我理解中,“异常”是比“错误”更严重的错误,或者说异常是错误的子集。发生异常时会终止当前处理的流程,例如终止当前请求、当前事务,甚至终止服务。大部分情况下,错误和异常在含义上相同。但有时候错误和异常的分界线并不清楚,而是需要根据不同的场景来定义的。例如打开配置文件的操作,如果找不到文件,对于某些场景,需要抛异常来提示配置文件不存在(流程被终止了);而对有些流程,只需要加载默认的配置即可,流程还是能继续走下去(这里相当于对错误做了降级处理)。

为什么会发生错误呢?或者说能不能把代码写得尽量完美,那样是不是就没有讨论这个话题的必要了?首先,一个方案难免会存在逻辑思维漏洞,或者考虑不周的情况,这时候错误也应该是预期之中的,所以我们有“迭代”的概念。另外,现在的工程代码,不再是几个人就能手撸出来的,要依赖了大量的外部组件、外部模块,外部服务等。种种的外部依赖也表示就算你的代码没问题,代码也不会一直遵循happy path走到底。

所以在现实中,如果服务出现可恢复的错误时,尽快恢复,不影响到服务继续运行;当不可恢复时,应做好妥善的后置操作,例如释放资源、保护用户数据、记录错误信息等,必要时重启服务。

2.1. 异常安全

boost库对于通用组件的异常安全的非正式定义是:模块中的异常安全意味着,当组件在执行过程中抛异常时,它会表现出合理的行为。对于大部分人,“合理”一词包括所有对错误处理的常见预期:资源不能泄漏;程序应该保持在一个明确定义的状态所以能继续执行。对于大多数组件,当错误发生时,应该让调用方知晓。

wikipedia定义稍微学术些:
异常安全,exception safety是,类库实现者和用户在任何带有异常的编程语言上,可以用来推导异常处理安全性的一系列的协议指导。其中包含4个等级(从强到弱):错误透明、强异常安全(事务语义)、基本异常安全、无异常安全。

2.2. 异常中立 exception neutrality

异常应该被特定的try catch块捕获并处理,并且允许未被捕获的异常继续向上传递。如果存在一个终止的catch(...),则需要最终把当前的异常
re-throw出去。简单而言,除非异常被捕获,不然异常对象应该保持不变,一直传上去。

3. C/C++方式

C语言的函数没法拥有多个返回值。caller想要得到函数执行的结果,又想拿到函数执行过程中可能引发的错误。这时候就有点让人头大了。所以C提供了一种方式来解决这一类问题,使用整数状态码(内部库实现用errno) + 指针的方式。

  • 状态码用来指示该函数执行是否成功,errno是线程安全的;
  • 指针提供了一种可以将函数改动后数据传递给函数外的方式;

C语言使用了状态码(status code)模式,但返回的状态码不为0并不代表发生了错误,而是不同的接口自己定义的。举个例子:

// 除法
int division(int dividend, int divisor, float* quotient) {
   if( divisor == 0){
      exit(-1);
   }
   // process divide
   exit(0);
}

// 查找子字符串
int index = find(str, sub_str)
if(index != -1) {// case 1
} else {// case 2
}

上面的例子中,除法遇到除数为0时,返回了-1的错误码,而caller需要根错误码映射关系才能知道错误的含义。而在查找的例子中,返回的状态码只表示找不到对应的子字符串,并不能代表程序发生了错误。这其实让C函数的返回含义严重依赖于文档说明,没有文档的话,除了阅读源码,不然你哪知道返回的-1是个啥意思。另外,无法简单得到函数的调用栈(当然你也可以说逐层的状态码检查其实不需要调用栈)。

到C++时,增加了对异常的支持,try-catch语法。代码可以写成

try {
    int code = operation();
    switch(code) {
        case case1:
        //...
        break;
        case case2:
        //...
        break;
        default:
        //...
        break;
} catch(...) {
    // exception handling
}

这样其实就把错误和异常给区分开了,对于错误,直接通过错误码来返回;而对于异常,通过throw Error的方式让外层catch住来处理,异常可以在调用栈的任意一层处理,如果不处理该异常,异常会一直往上冒泡。C++用RAII技术来保证异常安全,对象在析构的时候自己处理资源的释放等。C++的异常对比C的模式,能承载更多信息,包括错误信息,错误调用栈等。

4. 进入node.js

node.js的错误处理最佳实践可以参考Joyent的线上实践,下面的文字很多都是参考自该文章。这里之所以加上nodejs的错误处理,是我觉得一方面javascript是动态语言;另一方面,nodejs推崇异步操作。

Joyent将错误主要分为以下两大类:

  • 操作性错误 operational errors(以下简称OE):代表正确的代码在运行时触发的错误。代码里面没有bug,问题出现在其他地方:系统本身(OOM,打开太多文件),网络(请求超时,socket挂起)。
  • 程序员错误 programmer errors(以下简称PE):程序里面的bug。例如:少传递参数,参数类型不匹配(静态语言中,有类型检查和编译期,可以拦住其中的多种情况)。出于服务可用性考虑,从PE中恢复的最好方式就是立即崩溃,并重新启动。

node.js中有多种错误处理模式,包括

  • try-catch-finally
  • callback(err, …): 这种callback的错误传递模式看起来很像go语言的模式。
  • promise reject
  • event emit

通常会将后面三种归结为异步的错误传递,而第一种称为同步的错误传递。对于一段代码,可能会传递同步的错误(通过throw),也可能会传递异步的错误(通过传递到callback中、通过EventEmitter来触发),但不应该两种都使用。

上诉4种错误处理模式中,try-catch-finally是同步代码中最常见的处理模式,写法类似于C++。在异步代码中,callback是最基本的处理模式,但太多callback会引起callback hell的问题,promise的出现解决了这问题,所以promise reject是异步代码中最常见的处理方式。ES6(nodejs@8)中增加了async/await语法,让异步代码写起来和同步代码一样,同时也能在异步代码(仅限于async/await的异步代码)中加try-catch-finally来捕获异常了。event emit则在复杂的情况下才会派上用场,例如某些操作会产生多种结果或者多种错误、又或者操作存在多种状态。(值得注意的是,try-catch是无法在callback和event emit中捕获到throw出来的异常的)

5. 进入go

go对于错误处理的态度算是一股清流,真正区分开了错误和异常的处理。go语言的函数多值返回机制也为返回error提供了便利。而对于异常,未实现主流的try-catch-finally,采用了defer-panic-recover的语法。

We believe that coupling exceptions to a control structure, as in the try-catch-finally idiom, results in convoluted code. It also tends to encourage programmers to label too many ordinary errors, such as failing to open a file, as exceptional.

In Go, error handling is important. The language’s design and conventions encourage you to explicitly check for errors where they occur (as distinct from the convention in other languages of throwing exceptions and sometimes catching them).

go实现者认为将异常和控制结构耦合在一起,代码会变得复杂。并且好像在鼓励程序员将普通错误也标识成异常。go语言的设计鼓励程序员明确的处理每一个出现的错误。

The Go paradigm is to check for errors explicitly. A program should only panic if the circumstances under which it panics do not happen during ordinary program executing.

因为不想程序员滥用panic,go区分了error和exception(panic)的使用场景。在go社区之中,普遍认为尽可能不用panic。panic预示着这是个fatal的错误,程序应当立马终止。当使用panic时,你应当假设caller无法处理该问题,并且你的代码,或是集成了你代码的程序无法继续进行下去。

我理解中,

  • go的设计想区分开错误处理和控制流的跳转,在大量使用try-catch的语言中,代码并没有逐层去catch住异常,程序员随心所欲地在某个地方加上try-catch,这样便导致程序的控制流变得很复杂。
  • go真正赋予了异常”终止语义“。

当然,也有人觉得go的这种设计好像回退到C的时代,

rr := doStuff1()
if err != nil {
    //handle error...
}

err = doStuff2()
if err != nil {
    //handle error...
}

err = doStuff3()
if err != nil {
    //handle error...
}

上面代码看起来确实很像C的status code的方式,只不过将errno换成了error对象。但Go作者之一的Russ Cox觉得选择返回值的错误处理方式适用于大型项目,而try-catch的模式适合小程序。

6. 总结

以上写的只是我接触过的语言对错误处理的不同模式,但在我看来,只是不同语言推崇的处理模式(或者说编程习惯)不同,而在语言的机制上,给了相应的便利,但并不代表A语言没法采用B语言的错误处理模式。更多的是,透过不同语言的错误处理方式,能窥到作者对于这个语言的期望和设计思想。所以,还是一句老话,没有最好的技术,只有最适合场景的技术。

参考:

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值