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
}
这没什么不同,但有一个很大的区别。在这个代码中,客户端必须要在每次迭代中都检查错误,但在实际的 ScannerAPI 中,错误处理从 token 上迭代的 key API 元素中抽离出来。用实际的 API,客户端代码会感觉更自然:
- 循环直到结束再考虑错误。
- 错误处理不会模糊控制流。
在幕后发生的是,一旦 Scan 遇到 I / O 错误,它就会记录下来并返回 false。另外一个方法,Err,再客户端询问的时候报告错误值,尽管这很琐碎,但也不同于将 if err != nil 放到每个地方或是在每个 token 后要求客户端检查错误。
它是用错误值编程的。
很简单,是的,编程就是很简单。
值得强调的是,无论怎样设计,错误如何暴露,程序都必须检查错误。这里讨论的不是如何避免检查错误,而是优雅的处理错误。
当我出席东京 autumn 2014 GoCon 时,出现重复错误检查代码的话题。Twitter 用户 @jxck_是一个热情的 gopher,重复了关于错误检查的类似哀怨。
它有一些看起来大致如此的代码:
_, 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 变量需要在调用之间维护(try it)。
我们可以通过引入上面 Scan 方法点子,让其更清晰,更一般,可复用。我在我们的谈论中提到了这种技巧,但 @jxck_ 不知道如何使用。经过长时间的交流后,由于语言障碍,我问我是否可以借用他的笔记本电脑,并通过输入一些代码向他展示。
我定义了一个名为 errWriter 的对象,像这样:
I defined an object called an
errWriter, something like this:
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 方法就变成无操作,但错误值被保存。
给定 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_ 的全部内容,包括他记录的一小段视频,参见他的 blog。