说说令人不爽的if err != nil异常处理

发布时间:2022-10-11 11:11
最后更新:2022-10-11 11:11
所属分类:
Go

说到Go语言中被人吐槽最严重的内容,莫过于满眼的if err != nil判断结构了。大部分Gopher可能都觉得if err != nil非常丑陋,甚至在一些对比不同编程语言特性的漫画中还把Go语言比作了开一枪就必须检查的发令枪。但是事实果然如此么?

不同语言中的错误处理

这里所说的错误处理,也包括了异常处理,毕竟这里对比的语法功能是处理程序在运行时出现的错误。为了方便描述,这里统一先都称其为“异常”。

在常用的语言中,对于程序运行时异常的处理一般有两种方式。

  1. 以Java、C#、Javascript、Python为代表的使用try / catch结构进行异常捕获的处理方式。
  2. 以Go、Rust为代表的使用panic和返回error进行运行时错误处理的方式。

我们大抵是被Java惯坏了,所以才会觉得try / catch的异常处理结构是非常的有效率。有不少的人都在提议Go能够支持类似于try / catch这样的错误处理。不过这种提议的理由大多都是书写方便。例如Java或者Python中的异常处理。

1
2
3
4
5
6
7
public void maybeNull(Object something) {
  try {
    var a = something.Field;
  } catch (NullPointerException e) {
    // 处理产生的异常
  }
}
1
2
3
4
5
def maybeNull(something):
    try:
      a = somthing.Field
    except e:
      # 处理产生的异常

Go和Rust都采用了不同的错误处理方式,Rust采用捕获函数中的诧异,然后返回Result<T, Error>类型的返回值的方式。Go则采用直接从函数中返回一个元组,其中直接携带代表指向错误实例的指针的方式。例如Rust和Go中的异常处理。

1
2
3
4
5
6
7
fn some_function() -> Result<i64, ParseError> {
  // 完成一些处理,但是处理过程中发现了错误
  return Err(ParseError {
    message: "出现意料之外的解析错误。".to_string(),
    errCode: 20
  });
}
1
2
3
4
5
6
7
8
func someFunction(something *Obj) (int, error) {
  // 完成一些处理
  value, err := process(something)
  if err != nil {
    return -1, fmt.errorf("Some error occured: %w", err)
  }
  return value, nil
}

在上面的Go语言示例中,已经出现了经典的if err != nil判断,乍一看起来似乎这种方式真的挺烦人的,但是接下来我们仔细的研究一下这两种不同的异常处理方式。其实可以看出来,Rust和Go采用的都是返回携带有代表错误的实例的方式来捕获和处理错误。

栈展开

栈展开(Stack Unwinding)是程序中出现异常的常见操作,尤其是Java这种使用try / catch语句结构的语言中。

栈展开的常见操作逻辑如下:

@startuml
skinparam {
  BackgroundColor transparent
  ActivityBackgroundColor transparent
  ActivityDiamondBackgroundColor transparent
  NoteBackgroundColor transparent
  PartitionBackgroundColor transparent
}

:函数抛出异常;
repeat
  :记录异常的内容;
  :记录异常发生的位置;
  backward:获取上一级调用位置;
repeat while (抛出异常的位置周围是否存在try块) is (不存在) not (存在)
if (是否存在对应catch子句) then (存在)
  :执行catch子句内容;
  :释放涉及到的局部对象;
else (不存在)
  :结束程序执行;
  end
endif
:返回catch点之后继续执行;
@enduml

从这个简易的示意流程可以看出来,栈展开的过程就是运行时沿着调用链一层一层的去寻找异常处理的过程。在这个寻找过程中,程序一定是停下来的,最起码是出现异常的那个线程要停下来。

try / catch的优缺点

一个语言支持使用try / catch形式的异常处理,就说明这个语言一定是在出现异常的时候是采用栈展开的方式进行异常处理的。这种处理方式的优点自然不必说,因为一切都交给了运行时处理,所以对于编程人员来说就是简单,只需要找一个合适的位置处理可能出现的异常即可。

但是从栈展开的操作流程图也可以看出来,栈展开是一个十分低效的操作,尤其是栈展开的过程需要暂停异常线程的操作。尤其是像现在大部分Java应用都是基于Spring这些框架来编写,其调用链本身就已经十分深了,只要运行过程出现异常,就必须一层一层的像剥洋葱一样去展开,这实际上是很不经济的。

有一种编程模式推荐在代码中抛出各种各样的异常来用try / catch代替if / else,原因只是觉得if / else写起来不够高大上,大量的使用基础语句显得自己很没有水平,总是追求在代码上玩一些花活。但是仔细想想,if / else一般都是拥有十分底层的优化的,而且大多优化都是系统级或者硬件级的,但是try / catch的栈展开操作就没有什么优化可言了,只要抛出了异常,不管是真正的异常,还是人造的条件,都必须老老实实的去内存里翻去。

所以,究竟哪一种方式的效率更高,就不言而喻了。

一般伴随栈展开存在的,还有针对异常的现场保护,还有为了提供catch子句处理异常保存的栈展开链和所有层级的调用数据,这些内容都是要花费内存的,虽然这些数据在内容中占据的空间与内存的总容量显得非常微不足道。

直接返回错误实例的优缺点

这么一看,从函数中直接返回错误实例的方法的优点就很明显了——不用栈展开了。

函数在调用的时候,如果其内部发生了错误,函数自己是知道的,所以会直接终止自己的执行,把错误信息直接传递给自己的调用者。所以按照这个特性来整体的查看调用链的话,整个调用链在发生异常的时候,并不是展开的,而是收敛的。整条调用链还是跟正常运行的时候一样,很是会一层一层的正常结束。

这种错误处理在需要强调性能的程序中,尤为重要,因为线程无需再完全停下来处理异常,异常在程序中也只是一个正常的处理结果。

自然,缺点也就很明显了,对编程人员来说不是很友好,也就是满屏的if err != nil这种开一枪检查一下的发令枪模式,代码码时间长了容易让人烦躁。但函数正是需要通过这种模式来确定自己内部到底是哪里发生了错误,如果不用这种模式,那么函数就只能还是采用栈展开的方式去查找错误发生的位置。而且这种在错误发生地对错误进行检查和处理的方式也允许了更加自由和灵活的错误处理,你可以选择忽略,还可以选择包装一下交给上级调用者,或者立刻采用另一条处理流程,但无论怎样选择,都不会带来多少额外的性能开销。

其实仔细观察一下最近比较火的Rust,它里面也是满屏幕的.unwrap(),这实际上跟Go的if err != nil一样,无非就是需要敲的字少了一些而已。

对于Go语言来说,在使用的时候一定要牢记Go语言的设计思路:少即是多。复杂的设计并不一定高效,但简单的设计效率一定不会低。做对一件事情,一种方法已经足够,遍地的if err != nil就是最好的稳定性保障屏障。


索引标签
Go
异常处理
栈展开