错误是值 Errors are values
Rob Pike
12 January 2015
在程序员中,尤其是go新手,经常听到的一个讨论话题是:如何处理错误。当下面这段代码出现次数过多时,这个话题大多数时候都会变成对go的悲叹。
if err != nil {
return err
}
我们最近扫描了所有我们能找到的开源项目代码,但是确发现这段代码平均一两页出现一次,远没有我们原本设想的出现次数那么多。如果有人任然坚信一定要输入if err !=nil
,那么肯定是那里出错了,显然,是go本身的问题。
这是不幸,有误,并且容易纠正的一个问题。可能当go新手在询问”如何处理错误“的时候,他们学会了这个模式(指if err !=nil
),并且对错误的认知到此为止了。在其他语言,一个程序员使用try-catch
代码块或者而其他类似的机制去处理错误。因此,这些程序员们认为,在我之前的语言中我用try-catch
去处理错误,那么我在go中我也要用相应的if err != nil
处理错误就行了。随着时间过去,在go代码中出现许多这样的小片段,导致看起来十分愚蠢。
无论这个解释再怎么好,很明显的一点就是go程序员忽略了一个关于错误的概念:错误是值(Errors are value)。
我们可以对值进行编程,并且因为错误是值,所以我们也可以对错误进行编程。
原文:Values can be programmed, and since errors are values, errors can be programmed.
一个常见的涉及到错误的语句是测试错误值是不是空值,但是我们还可以对错误值进行无数种处理,使用其中一些处理,可以让我们的程序变得更好,消灭那些每一错误都要用样板if
处理的语句。
这里有一个简单例子,来自bufio
包的Scanner
类,它的Scan
方法执行基础的I/O,这其中明显可能产生错误,然而,scan
方法并没有一个错误,而是返回一个布尔值,并且在scan
方法执行结束后,运行一个单独的方法,报告是否单独发生错误。客户端代码如下
scanner := bufio.NewScanner(input)
for scanner.Scan() {
token := scanner.Text()
// process token
}
if err := scanner.Err(); err != nil {
// process the error
}
当然,可以对每个error进行一次空值检查,但是它出现并执行一次,Scan
方法也可以被定义如下
func (s *Scanner) Scan() (token []byte, error)
用户的代码样例如下:
scanner := bufio.NewScanner(input)
for {
token, err := scanner.Scan()
if err != nil {
return err // or maybe break
}
// process token
}
这两个没有多大的不同,但是有一个很重要的区别是,在后者的客户端代码,用户必须在每次循环中都检查一次错误,但是在真正的Scanner
API中,只会对token
进行迭代,错误处理从关键API代码中抽象出来了。所以在真实的API中,用户端的代码因此更加自然,一直循环直到结束,然后再考虑错误,错误处理并不会影响到控制流。
在这背后发生了什么呢?当scan
遇到了I/O错误,它会记录并且返回false
,另一个单独的方法Err
,报告这个错误当客户端调用它的时候,虽然这有些琐碎,但是不同于到处输入if err != nil
,或者告诉客户必须时刻检查每个token是否有错误。这个方法对错误进行了编程。简单的编程处理,仍然是编程。
值得强调的是,无论使用哪种设计,最至关重要的是,无论错误如何暴露出来,都要去检查处理它。我们在这的讨论,不是讨论如何避免检查错误,而是如何使用go更优雅地处理错误。
我在东京参加2014年秋季GoCon大会时,反复出现了错误检查代码的话题。一个热情的 gopher,@jxck,反映了他对错误处理的抱怨,他有一些像这样的代码:
_, err = fd.Write(p0[a:b])
if err != nil {
return err
}
_, err = fd.Write(p1[c:d])
if err != nil {
return err
}
_, err = fd.Write(p2[e:f])
if err != nil {
return err
}
// and so on
这出现了过多重复的代码。在真实的编码过程中,它更长些,并且有更多情况要处理,因此用一个辅助函数去重构它并不容易,但是在理想环境中,定义一个函数来处理错误变量或许能帮助我们
var err error
write := func(buf []byte) {
if err != nil {
return
}
_, err = w.Write(buf)
}
write(p0[a:b])
write(p1[c:d])
write(p2[e:f])
// and so on
if err != nil {
return err
}
这个方式看起来不错,但是在执行写操作的时候,每个函数都需要闭包操作,每次单独的辅助函数调用的时候都需要维护一个共同变量。
我们可以做得更干脆点,更适用点,并且复用性更高,通过借鉴上文的Scan
方法,我向@jxck提到了这个技巧,但是他不知道如何去使用,在一段时间的交流后,由于语言障碍,我借用他的电脑,写下了这样一段代码,试着去提醒他。
我定义了一个对象叫做errWriter
,如下:
type errWriter struct {
w io.Writer
err error
}
并且给他赋予一个方法write
,他并不需要标准的Write方法
签名,并且他的小写突出了区别。写方法调用了Write
方法,并且记录了第一个遇到的错误用于后续使用。
func (ew *errWriter) write(buf []byte) {
if ew.err != nil {
return
}
_, ew.err = ew.w.Write(buf)
}
当错误发生时,write
将会变成一个无操作函数,但是会保存错误值。
定义errWriter
类型,并且他的write
方法,上述代码可以被重构为
ew := &errWriter{w: fd}
ew.write(p0[a:b])
ew.write(p1[c:d])
ew.write(p2[e:f])
// and so on
if ew.err != nil {
return ew.err
}
相比比起闭包的方法,这种写法更为清晰且透明。再也不杂乱了,使用错误值编程,可以使得代码更漂亮。
在一些标准包中,其他一些代码也可以基于这个思想去构建,甚至直接使用errWriter
。
而且,errWriter
的存在,还可以做更多的事情来提供帮助,特别是在不那么人工的例子中,他可以积累字节数,可以将写操作合并到一个缓冲区中,以原子的方式操作。
事实上,这个方式经常出现在标准包汇总。archive/zip
和 net/http
都有使用到这个方法。本文的讨论更为突出的表现了 bufio
package's Writer
就是基于errWriter
想法实现的,虽然 bufio.Writer.Write
返回一个错误,这主要是基于io.Writer
接口。Writer
方法的实现就像我们上文写的errWriter.write
方法,Flush
报告错误,使得我们的例子可以如下:
b := bufio.NewWriter(fd)
b.Write(p0[a:b])
b.Write(p1[c:d])
b.Write(p2[e:f])
// and so on
if b.Flush() != nil {
return b.Flush()
}
至少对于某些应用程序,此方法存在一个明显的缺点:无法知道发生错误之前已完成多少处理。如果该信息很重要,则必须采用更细粒度的方法。但是,通常,最后进行全有或全无的检查就足够了。
我们只研究了一种避免重复错误处理代码的技术。请记住,使用 errWriter 或 bufio.Writer 并不是简化错误处理的唯一方法,并且这种方法并不适合所有情况。但是,关键的教训是,错误是值,而 Go 编程语言的全部功能可用于处理它们。
使用该语言简化错误处理。
但是请记住:无论做什么,都要检查错误!