Go 博客
错误是值
Go 程序员,尤其是新入门的程序员,经常会讨论如何处理错误。讨论常常会变成对以下代码序列出现次数的抱怨:
if err != nil {
return err
}
我们最近扫描了所有能找到的开源项目,发现这段代码平均每页或两页只出现一次,比某些人认为的要少。尽管如此,如果人们仍然觉得必须一直输入
if err != nil
,那么一定有问题,而最明显的矛头就是 Go 本身。
这是不幸的、误导性的,并且很容易纠正。也许发生的情况是,Go 新手会问:“如何处理错误?”,然后学到这种模式,就停止了。在其他语言中,人们可能会使用 try-catch 块或其他类似的机制来处理错误。因此,程序员会想,在我以前的语言中会使用 try-catch 的地方,现在在 Go 中我就只输入 if
err
!=
nil
。随着时间的推移,Go 代码会累积许多这样的代码片段,感觉很笨拙。
无论这个解释是否贴切,很明显这些 Go 程序员忽略了一个关于错误的基本点:错误是值。
值是可以编程的,而由于错误是值,错误也可以被编程。
当然,一个常见的涉及错误值的操作是测试它是否为 nil,但你还可以对错误值做无数其他事情,并且应用其中一些其他操作可以使你的程序更好,消除如果每个错误都用死板的 if 语句来检查所产生的许多样板代码。
这里有一个来自 bufio
包的 Scanner
类型的简单示例。它的 Scan
方法执行底层的 I/O,当然这可能导致错误。然而,Scan
方法根本不暴露错误。相反,它返回一个布尔值,并且一个独立的方法在扫描结束时报告是否发生了错误。客户端代码看起来是这样的:
scanner := bufio.NewScanner(input)
for scanner.Scan() {
token := scanner.Text()
// process token
}
if err := scanner.Err(); err != nil {
// process the error
}
当然,这里有一个对错误的 nil 检查,但它只出现并执行一次。Scan
方法本可以定义为:
func (s *Scanner) Scan() (token []byte, error)
然后示例用户代码可能是(取决于如何检索 token):
scanner := bufio.NewScanner(input)
for {
token, err := scanner.Scan()
if err != nil {
return err // or maybe break
}
// process token
}
这差别不大,但有一个重要的区别。在这段代码中,客户端必须在每次迭代时检查错误,但在真实的 Scanner
API 中,错误处理已经从关键的 API 元素(即迭代 token)中抽象出来了。使用真实的 API,客户端的代码感觉更自然:循环直到完成,然后处理错误。错误处理不会干扰控制流。
在底层,当 Scan
遇到 I/O 错误时,它会记录下来并返回 false
。一个独立的方法 Err
在客户端请求时报告错误值。虽然这很小,但与在
if err != nil
处到处都是,或者要求客户端在每个 token 后都检查错误,是不同的。这是使用错误值进行编程。简单的编程,是的,但毕竟是编程。
值得强调的是,无论设计如何,程序都必须检查错误,无论它们如何被暴露。这里的讨论不是关于如何避免检查错误,而是关于如何优雅地使用语言来处理错误。
关于重复性错误检查代码的话题,在我参加 2014 年秋季东京 GoCon 会议时出现了。一位热情的 gopher,在 Twitter 上叫做 @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
}
这种模式效果很好,但需要在每个执行写入的函数中使用闭包;单独的辅助函数使用起来更麻烦,因为 err
变量需要在调用之间维护(可以试试)。
我们可以通过借鉴上面 Scan
方法的思路,使其更简洁、更通用、更可重用。我在讨论中提到了这个技巧,但 @jxck_
没有看到如何应用它。经过长时间的交流,由于语言障碍有所影响,我问他是否可以借用他的笔记本电脑,通过输入一些代码来向他展示。
我定义了一个名为 errWriter
的对象,大致如下:
type errWriter struct {
w io.Writer
err error
}
并给它一个方法 write
。它不需要具有标准的 Write
签名,并且小写部分是为了突出区别。write
方法调用底层 Writer
的 Write
方法,并记录第一个错误以供将来参考:
func (ew *errWriter) write(buf []byte) {
if ew.err != nil {
return
}
_, ew.err = ew.w.Write(buf)
}
一旦发生错误,write
方法就变成了一个 no-op(空操作),但错误值被保存下来。
有了 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
包的 Writer
实际上是 errWriter
思想的一种实现。虽然 bufio.Writer.Write
返回一个错误,但这主要是为了遵循 io.Writer
接口。bufio.Writer
的 Write
方法的行为就像我们上面的 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 编程语言的全部功能都可以用于处理它们。
使用语言来简化你的错误处理。
但请记住:无论你做什么,都要始终检查你的错误!
最后,关于我和 @jxck_ 互动的完整故事,包括他录制的一个小视频,请访问他的博客。
下一篇文章:包名
上一篇文章:GothamGo:大苹果里的 gophers
博客索引