Go 语言的错误处理机制引发争议

jopen 6年前

最近,有关Go语言的错误处理机制在社区中展开了讨论,有人认为冗长重复的错误处理格式像是回到了上世纪七十年代,而Go语言的开发者给予了反驳。

Go语言的错误处理机制可以从支持函数多返回值说起:

在C语言当中常见的做法是保留一个返回值来表示错误(比如,read()返回0),或 者保留返回值来通知状态,并将传递存储结果的内存地址的指针。这容易产生了不安全的编程实践,因此在像Go语言这样有良好管理的语言中是不可行的。认识到 这一问题的影响已超出了函数结果与错误通讯的简单需求的范畴,Go的作者们在语言中内建了函数返回多个值的能力。作为例子,这个函数将返回整数除法的两个部分:

func divide(a, b int) (int, int) {    quotient := a / b    remainder := a % b    return quotient, remainder  }

多返回值的出现促进了"comma-ok"的模式。有可能失败的函数可以返回第二个布尔结果来表示成功。作为替代,也可以返回一个错误对象,因此像下面这样的代码也就不见怪了:

if result, ok := moreMagic(); ok {    /* Do something with result */  }

除此之外,Go语言还提供了Panic/Recover机制,陈皓在“Go语言简介”中有比较详细的描述:

对于不可恢复的错误,Go提供了一个内建的panic函数,它将创建一个运行时错误并使程序停止(相当暴力)。该函数接收一个任意类型(往往是字符串)作为程序死亡时要打印的东西。当编译器在函数的结尾处检查到一个panic时,就会停止进行常规的return语句检查。

下面的仅仅是一个示例。实际的库函数应避免panic。如果问题可以容忍,最好是让事情继续下去而不是终止整个程序。

var user = os.Getenv("USER")  func init() {    if user == "" {      panic("no value for $USER")    }  }

当panic被调用时,它将立即停止当前函数的执行并开始逐级解开函数堆栈,同时运行所有被defer的函数。如果这种解开达到堆栈的顶端,程序就 死亡了。但是,也可以使用内建的recover函数来重新获得Go程的控制权并恢复正常的执行。 对recover的调用会通知解开堆栈并返回传递到panic的参量。由于仅在解开期间运行的代码处在被defer的函数之内,recover仅在被延期 的函数内部才是有用的。

你可以简单地理解为recover就是用来捕捉Painc的,防止程序一下子就挂掉了。

Python和Go语言的实践者Yuval Greenfield在“Why I’m not leaving Python for Go”的博文中批评了Go语言的错误处理机制。他首先引用了Go语言的设计者对错误处理机制的看法:

在Go语言中,错误处理非常重要。语言的设计和规范鼓励开发人员显式地检查错误(与其他语言抛出异常然后catch住是不同的)。这种机制某种程度上使得Go语言的代码冗长重复,但是幸运的是你可以利用一些技巧来把冗长的代码最小化。

Yuval表示这点他无法忍受,每一次函数的调用都需要if语句来判断是否出现错误,他引用了一段官方的所谓最小化代码量的错误处理示例:

if err := datastore.Get(c, key, record); err != nil {    return &appError{err, "Record not found", 404}  }  if err := viewTemplate.Execute(w, record); err != nil {    return &appError{err, "Can't display record", 500}  }

Yuval说,这就是在Go语言中调用函数的正确处理方式,甚至连Println的调用都要这样做。如果不这么做会怎样呢?Go语言并没有坚持要采 用这种冗长的错误机制。它也允许忽略这些函数调用错误。但是这样做很危险。在下面的例子中,如果第一个Get函数错误,那么程序继续调用第二个函数!这是 非常恐怖的事情。

func main() {      http.Get("http://www.nuke.gov/seal_presidential_bunker")      http.Get("http://www.nuke.gov/trigger_doomsday_device")  }

理论上,我们要求开发人员决不能忽略返回的错误。而实际上,只有在一些关键性的错误上面处理才是必要的。

关于panic/recover机制,Yuval认为也不够出色,因为连Go的标准库都不怎么用这种机制:为什么索引溢出的数组要比错误格式的字符 串或者失败的网络连接更需要panic呢?Go语言希望能够完全避免异常,但实际上不能,总有一些异常会在某处发生,让开发人员在错误出现时感到困惑。

针对Yuval的批评,Go的开发者Russ Cox做出了回应

在Go语言中,规定的方式是,函数返回错误信息。如果一个文件并不存在,op.Open函数会返回一个错误信息。如果你向你一个中断了的网络连接里 写数据,net.Conn里的Write方法会返回一个错误。这种状况在这种程序中是可以预料到的。这种操作就是容易失败,你知道程序会如何运行,因为 API的设计者通过内置了一种错误情况的结果而让这一切显得很清楚。

从另一方面讲,有些操作基本上不会出错,所处的环境根本不可能给你提示错误信息,不可能控制错误。这才是让人痛苦的地方。典型的例子;一个程序执行 x[j],j值超出数组边界,这才痛苦。像这样预料之外的麻烦在程序中是一个严重的bug,一般会弄死程序的运行。不幸的是,由于这种情况的存在,我们很 难写出健壮的,具有自我防御的服务器——例如,可以应付偶然出现的有bug的HTTP请求处理器时,不影响其他服务的启动和运行。为解决这个问题,我们引 入了恢复机制,它能让一个go例程从错误中恢复,服务余下设定的调用。然而,代价是,至少会丢失一个调用。这是特意而为之的。引用邮件中的原话:“这种设 计不同于常见的异常控制结构,这是一个认真思考后的决定。我们不希望像java语言里那样把错误和异常混为一谈。”

Russ Cox针对“为什么数组越界造成的麻烦会比错误的网址或断掉的网络引出的问题要大?”这个问题给出了自己的答案:

我们没有一种内联并行的方法来报告在执行x[j]期间产生的错误,但我们有内联并行的方法报告由错误网址或网络问题造成的错误。

使用Go语言中的错误返回模式的规则很简单:如果你的函数在某种情况下很容易出错,那它就应该返回错误。当我调用其它的程序库时,如果它是这样写的,那我不必担心那些错误的产生,除非有真正异常的状况,我根本没有想到需要处理它们。

最后,Russ Cox指出Go语言是为大型软件设计的:

我们都喜欢程序简洁清晰,但对于一个由很多程序员一起开发的大型软件,维护成本的增加很难让程序简洁。异常捕捉模式的错误处理方式的一个很有吸引力 的特点是,它非常适合小程序。但对于大型程序库,如果对于一些普通操作,你都需要考虑每行代码是否会抛出异常、是否有必要捕捉处理,这对于开发效率和程序 员的时间来说都是非常严重的拖累。我自己做开发大型Python软件时感受到了这个问题。Go语言的返回错误方式,不可否认,对于调用者不是很方便,但这 样做会让程序中可能会出错的地方显的很明显。对于小程序来说,你可能只想打印出错误,退出程序。对于一些很精密的程序,根据异常的不同,来源的不同,程序 会做出不同的反应,这很常见,这种情况中,try + catch的方式相对于错误返回模式显得冗长。当然,Python里的一个10行的代码放到Go语言里很可能会更冗长。毕竟,Go语言主要不是针对10行 规模的程序的。

作者:InfoQ/崔康 热情的技术探索者,资深软件工程师,InfoQ编辑,从事企业级Web应用的相关工作,关注性能优化、Web技术、浏览器等领域。