Go 博客
错误是值
Go 程序员,尤其是刚接触这门语言的程序员,经常讨论如何处理错误。谈话通常会变成对以下代码片段出现的次数的抱怨
if err != nil {
return err
}
我们最近扫描了所有能找到的开源项目,发现这段代码每隔一两页就会出现一次,比有些人想象的要少。但是,如果人们仍然认为必须一直输入
if err != nil
那就肯定有问题了,Go 本身就是显而易见的解决目标。
这很不幸,也很误导,而且很容易纠正。也许正在发生的事情是,刚接触 Go 的程序员会问:“如何处理错误?”,然后学习这种模式,就止步于此。在其他语言中,人们可能会使用 try-catch 块或其他类似机制来处理错误。因此,程序员认为,当我过去在旧语言中使用 try-catch 时,我只需在 Go 中输入 if
err
!=
nil
就可以了。随着时间的推移,Go 代码收集了许多这样的代码片段,结果感觉很笨拙。
无论这种解释是否符合实际,很明显这些 Go 程序员忽略了关于错误的一个基本点:错误是值。
值可以被编程,既然错误是值,那么错误也可以被编程。
当然,涉及错误值的常见语句是测试它是否为空,但人们可以使用错误值做无数其他的事情,其中一些事情的应用可以使您的程序变得更好,消除如果每个错误都用一个刻板的 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
}
当然,这里有一个检查错误是否为空的语句,但它只出现并执行一次。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 中,错误处理从关键 API 元素(即迭代标记)中抽象出来。使用真实的 API,客户端的代码因此感觉更自然:循环直到完成,然后处理错误。错误处理不会掩盖控制流。
在幕后,正在发生的事情是,一旦 Scan
遇到 I/O 错误,它就会记录该错误并返回 false
。一个单独的方法,Err
,会在客户端询问时报告错误值。尽管这很简单,但它与在每个地方都放置
if err != nil
或要求客户端在每个标记后检查错误不同。它是使用错误值进行编程。简单的编程,是的,但毕竟是编程。
值得强调的是,无论设计如何,程序都必须检查错误,无论它们是如何暴露的。这里讨论的不是如何避免检查错误,而是关于如何使用语言优雅地处理错误。
我在 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
方法就会变成空操作,但错误值会被保存。
有了 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
博客索引